fix(rectification): centralize delivery authority
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
# 生时校正决策权威重构进度(2026-08-31)
|
||||
|
||||
## 基线与交付边界
|
||||
|
||||
- 隔离 worktree:`/Users/jesse/Downloads/Copse/astrology/.worktrees/rectification-decision-authority-20260831`
|
||||
- 分支:`codex/rectification-decision-authority-20260831`
|
||||
- 基线:`origin/staging` @ `9aebd0474a089ed5bec1fc8e8432d0d85e93ffb6`
|
||||
- 2026-08-31 交付前重新执行 `git fetch origin staging --prune`;本地基线与远端 `staging` 仍一致。
|
||||
- 本次只交付任务 0、A、B。任务 C、D 尚未开始;不部署,不提升 `main`,不声称真实 authenticated staging flow 已验证。
|
||||
|
||||
## 任务 0:不变量门控
|
||||
|
||||
实现前先加入五条不变量并运行,五条均红,且失败原因分别落在任务书列出的病灶:
|
||||
|
||||
1. TypeScript 能力超过引擎 ceiling。
|
||||
2. 证据不足仍允许采用/选择。
|
||||
3. 候选并列或未分离仍允许采用。
|
||||
4. holdout unavailable 仍允许采用。
|
||||
5. public overlay 用本地字段覆盖引擎否决。
|
||||
|
||||
A+B 完成后五条全部转绿:`tests 5 / pass 5 / fail 0`。
|
||||
|
||||
## 任务 A:停止原因分类化
|
||||
|
||||
- 新增三类 `StopClass`:`keep_collecting`、`exhausted`、`user_stopped`。
|
||||
- `insufficient_dated_events` 与 `insufficient_domains` 只进入收集流程,不再进入 `completeWithRange()`。
|
||||
- 真正耗尽(并列第一、用户不确定度过高)仍可结束为 review-only range。
|
||||
- 用户主动停止改变的是“是否继续追问”,不是证据是否达标:允许交付区间,但不放开采用能力。
|
||||
- 空候选不再返回无停止原因的 fail-open 状态,而是归入证据不足、继续收集。
|
||||
- `decideNextAction()` 与 `decideRectification()` 同步演进,保留既有一致性断言。
|
||||
|
||||
## 任务 B:能力标志单一来源与引擎上限
|
||||
|
||||
- 新增严格四字段 `EngineCapabilityCeiling`:
|
||||
- `acceptanceAllowed`
|
||||
- `selectionAllowed`
|
||||
- `proposeAllowed`
|
||||
- `confirmationAllowed`
|
||||
- `decisionReceipt` 四字段缺失、非 boolean 或字段关系非法时 fail-closed。raw dossier receipt 还会严格校验 `accept_allowed` / `confirm_allowed` alias 与 canonical 字段一致,并校验 `display_allowed` 不得与已允许 selection 的结果冲突。
|
||||
- `deliveryCapability()` 成为以下四字段的唯一计算点:
|
||||
- `canAdopt`
|
||||
- `selectionAllowed`
|
||||
- `proposeAllowed`
|
||||
- `canConfirmExactMinute`
|
||||
- 本地证据、coverage、holdout、separation 门与引擎 ceiling 取交集;TypeScript 只能收紧,不能放宽。
|
||||
- `overlayPublicDecision()` 对四个门禁字段取本地结果与 receipt ceiling 的逻辑与,保留候选投影不一致时的 fail-closed。
|
||||
- `v9/engine-client.ts` 扩展返回四个引擎能力字段。
|
||||
- `v9/decision-from-dossier.ts` 三个调用点、`core/decide-next-action.ts`、`v9/method-followup.ts` 已逐一处理。
|
||||
- `method-followup.ts` 的 helper 不参与生产交付,只用于问题流程,因此显式传关闭 ceiling;流程走向仍可 collect/discriminate,不会因四个交付能力全 false 而静默。
|
||||
- `v9/answer-choice.ts` 的采用 narration 改为绑定最终能力 `can_offer_range && can_adopt`,避免 `ready_to_adopt` 丢失同回合采用载体。
|
||||
- `v9/turn-decision.ts` 继续写入 `decision.selectionAllowed`;该字段现在已是 `deliveryCapability()` 的最终结果,无另行判门。
|
||||
- `frontend/src/lib/rectification-agentic/rectification-agentic/session.ts` 在 `frontend/src` 下无生产 import,属于非活跃 legacy 路径,本轮不改。
|
||||
- `decideConversationalSession()` 仍未接入生产路径;既有“生产工具不得调用它”的测试约束未解除。
|
||||
|
||||
## B-1 grep 自证
|
||||
|
||||
以下六个分支函数内,四个能力字段均无 boolean 字面量赋值:
|
||||
|
||||
```text
|
||||
collect: OK
|
||||
discriminate: OK
|
||||
offerRangeWithoutAdopt: OK
|
||||
holdoutValidation: OK
|
||||
completeWithRange: OK
|
||||
finish: OK
|
||||
```
|
||||
|
||||
## 事故输入回归
|
||||
|
||||
输入:
|
||||
|
||||
- 1 条日期证据
|
||||
- 1 个 domain
|
||||
- 9 个候选分数:`12,12,12,12,11,11,10,10,10`
|
||||
- `trainingGateOpen=false`
|
||||
- `methodCoverageAll=false`
|
||||
- `snapshotCurrent=true`
|
||||
- 引擎 ceiling 四字段全 false
|
||||
|
||||
重构前任务书记录:
|
||||
|
||||
```json
|
||||
{
|
||||
"canAdopt": true,
|
||||
"selectionAllowed": true,
|
||||
"nextAction": "complete_with_range",
|
||||
"stopReason": "insufficient_dated_events"
|
||||
}
|
||||
```
|
||||
|
||||
当前输出:
|
||||
|
||||
```json
|
||||
{
|
||||
"canAdopt": false,
|
||||
"selectionAllowed": false,
|
||||
"proposeAllowed": false,
|
||||
"canConfirmExactMinute": false,
|
||||
"nextAction": "ask_fact_collection",
|
||||
"sessionOutcome": "collect_evidence",
|
||||
"stopReason": "insufficient_dated_events",
|
||||
"canOfferRange": false
|
||||
}
|
||||
```
|
||||
|
||||
## 既有断言修改配额
|
||||
|
||||
按用户批准的新口径:
|
||||
|
||||
- 错误行为组:`6 / 6`
|
||||
- 修改的既有断言:`10 / 12`
|
||||
|
||||
前三条先前未列明的断言为:
|
||||
|
||||
1. `frontend/tests/rectification-convergence-budget.test.ts:172`:`canAdopt true → false`;病灶 3,候选未分开且预算耗尽只能交付 review-only range。
|
||||
2. `frontend/tests/rectification-convergence-budget.test.ts:412`:label `blocked → user_history_verification_required`;病灶 1,空候选必须分类为证据不足而不是无原因 fail-open。
|
||||
3. `frontend/tests/rectification-decide-next-action.test.ts:511`:`sessionOutcome adopt_representative → provisional_range`;病灶 4,holdout unavailable 不能进入采用态。
|
||||
|
||||
完整清单(按错误行为组):
|
||||
|
||||
### 组 1:候选未分离却进入采用态(病灶 3)
|
||||
|
||||
1. `frontend/tests/rectification-answer-choice.test.ts:608`:`nextAction offer_provisional_range → ready_to_adopt`。原 fixture 为 `16/14`,回答后仍未达到 separation 门却依赖错误采用行为;fixture 已收窄修正为刚好达门的 `18/14` 并通过 holdout,正确采用态是 `ready_to_adopt`。
|
||||
2. `frontend/tests/rectification-convergence-budget.test.ts:172`:`canAdopt true → false`。预算耗尽不等于候选已分开。
|
||||
|
||||
### 组 2:空候选缺少停止分类(病灶 1)
|
||||
|
||||
3. `frontend/tests/rectification-convergence-budget.test.ts:412`:label `blocked → user_history_verification_required`。空候选应继续收集日期证据。
|
||||
|
||||
### 组 3:无 holdout 的可信区间被当作可采用(病灶 4)
|
||||
|
||||
4. `frontend/tests/rectification-credible-range-projection.test.ts:146`:`nextAction ready_to_adopt → offer_provisional_range`。无 holdout 只能 review-only。
|
||||
|
||||
### 组 4:单候选但 holdout unavailable 被当作可采用(病灶 4)
|
||||
|
||||
5. `frontend/tests/rectification-decide-next-action.test.ts:511`:`sessionOutcome adopt_representative → provisional_range`。单候选不覆盖独立核对门。
|
||||
|
||||
### 组 5:sticky holdout 失去日期精度后仍可采用(病灶 4)
|
||||
|
||||
6. `frontend/tests/rectification-holdout-renderable.test.ts:151`:`nextAction ready_to_adopt → offer_provisional_range`。
|
||||
7. `frontend/tests/rectification-holdout-renderable.test.ts:153`:`sessionOutcome adopt_representative → provisional_range`。
|
||||
8. `frontend/tests/rectification-holdout-renderable.test.ts:157`:`canAdopt true → false`。
|
||||
|
||||
三条均由同一错误行为产生:sticky 只保留 holdout 身份,unknown precision 使其不可验证,必须 fail-closed。
|
||||
|
||||
### 组 6:用户停止绕过证据与 coverage 门(病灶 2)
|
||||
|
||||
9. `frontend/tests/rectification-range-offer-deadend.test.ts:601`:`canAdopt true → false`。用户停止只允许展示区间。
|
||||
10. `frontend/tests/rectification-range-offer-deadend.test.ts:608`:`canAdopt true → false`。两条同域日期事件仍不满足独立交付采用门。
|
||||
|
||||
`frontend/tests/rectification-holdout-renderable.test.ts` 中 `passed holdout` 的 `canAdopt === true` 保持不变;只补全 receipt fixture。
|
||||
|
||||
## 未放宽的门槛
|
||||
|
||||
- `MIN_STANDALONE_DATED_EVENTS = 3`
|
||||
- `MIN_STANDALONE_DATED_DOMAINS = 2`
|
||||
- `MIN_SEPARATION_LEAD = 8`
|
||||
- 4 事件 / 3 domain 的 exact-minute confirmation gate
|
||||
|
||||
## 本地验收
|
||||
|
||||
- `./node_modules/.bin/tsx --test --test-name-pattern='^invariant [1-5]:' tests/rectification-decision-authority.test.ts`:`5/5` 通过。
|
||||
- `./node_modules/.bin/tsx --test tests/rectification-answer-choice.test.ts`:`27/27` 通过。
|
||||
- `./node_modules/.bin/tsx --test tests/rectification-*.test.ts`:`712/712` 通过,`fail=0`。
|
||||
- `./node_modules/.bin/tsc --noEmit`:exit 0。
|
||||
- `git diff --check`:exit 0。
|
||||
- `.gitea/workflows/**`:无改动。
|
||||
|
||||
## 尚未执行
|
||||
|
||||
- 任务 C:前端 selection card 的独立 receipt fail-closed。
|
||||
- 任务 D:问题槽单一文案源与 Skill `10.0.14` 五处同步升版。
|
||||
- staging 部署与真实 authenticated flow 验证。
|
||||
@@ -56,8 +56,12 @@ export function decideNextAction(input: DecideNextActionInput): RectificationNex
|
||||
holdoutValidation: input.holdoutValidation,
|
||||
accepted: input.accepted,
|
||||
inferenceCredibleRange: input.inferenceCredibleRange,
|
||||
engineAcceptAllowed: input.selectionAllowed,
|
||||
engineProposeAllowed: input.proposeAllowed,
|
||||
engineCeiling: {
|
||||
acceptanceAllowed: input.selectionAllowed === true,
|
||||
selectionAllowed: input.selectionAllowed === true,
|
||||
proposeAllowed: input.proposeAllowed === true,
|
||||
confirmationAllowed: input.confirmationAllowed === true,
|
||||
},
|
||||
inferenceRounds: input.inferenceRounds,
|
||||
effectiveAnswerCount: input.effectiveAnswerCount,
|
||||
plateauRounds: input.plateauRounds,
|
||||
|
||||
@@ -38,6 +38,61 @@ export type EvidenceStopReason =
|
||||
| "tied_first"
|
||||
| "user_uncertainty_too_high";
|
||||
|
||||
export type StopClass =
|
||||
| Readonly<{ kind: "keep_collecting"; reason: "insufficient_dated_events" | "insufficient_domains" }>
|
||||
| Readonly<{ kind: "exhausted"; reason: "tied_first" | "user_uncertainty_too_high" }>
|
||||
| Readonly<{ kind: "user_stopped" }>;
|
||||
|
||||
export type EngineCapabilityCeiling = Readonly<{
|
||||
acceptanceAllowed: boolean;
|
||||
selectionAllowed: boolean;
|
||||
proposeAllowed: boolean;
|
||||
confirmationAllowed: boolean;
|
||||
}>;
|
||||
|
||||
type DeliveryCapability = Readonly<{
|
||||
canAdopt: boolean;
|
||||
selectionAllowed: boolean;
|
||||
proposeAllowed: boolean;
|
||||
canConfirmExactMinute: boolean;
|
||||
}>;
|
||||
|
||||
const CLOSED_ENGINE_CAPABILITY_CEILING: EngineCapabilityCeiling = {
|
||||
acceptanceAllowed: false,
|
||||
selectionAllowed: false,
|
||||
proposeAllowed: false,
|
||||
confirmationAllowed: false,
|
||||
};
|
||||
|
||||
export function engineCapabilityCeilingFromReceipt(value: unknown): EngineCapabilityCeiling {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return CLOSED_ENGINE_CAPABILITY_CEILING;
|
||||
}
|
||||
const row = value as Readonly<Record<string, unknown>>;
|
||||
const acceptanceAllowed = row.acceptance_allowed;
|
||||
const selectionAllowed = row.selection_allowed;
|
||||
const proposeAllowed = row.propose_allowed;
|
||||
const confirmationAllowed = row.confirmation_allowed;
|
||||
if (
|
||||
typeof acceptanceAllowed !== "boolean"
|
||||
|| typeof selectionAllowed !== "boolean"
|
||||
|| typeof proposeAllowed !== "boolean"
|
||||
|| typeof confirmationAllowed !== "boolean"
|
||||
|| acceptanceAllowed !== selectionAllowed
|
||||
|| (row.accept_allowed !== undefined
|
||||
&& (typeof row.accept_allowed !== "boolean" || row.accept_allowed !== selectionAllowed))
|
||||
|| (row.confirm_allowed !== undefined
|
||||
&& (typeof row.confirm_allowed !== "boolean" || row.confirm_allowed !== confirmationAllowed))
|
||||
|| (row.display_allowed !== undefined
|
||||
&& (typeof row.display_allowed !== "boolean" || (selectionAllowed && !row.display_allowed)))
|
||||
|| (proposeAllowed && !selectionAllowed)
|
||||
|| (confirmationAllowed && !selectionAllowed)
|
||||
) {
|
||||
return CLOSED_ENGINE_CAPABILITY_CEILING;
|
||||
}
|
||||
return { acceptanceAllowed, selectionAllowed, proposeAllowed, confirmationAllowed };
|
||||
}
|
||||
|
||||
export const RECTIFICATION_TERMINATION_COPY = "当前最优结果是候选时间段,而不是已经确认的唯一出生分钟。临时代表时间仅用于下一轮验证与比较。";
|
||||
|
||||
/** Standalone range-delivery floor; the exact-minute confirmation gate remains 4/3. */
|
||||
@@ -98,8 +153,7 @@ export type DecideRectificationInput = Readonly<{
|
||||
holdoutValidation?: HoldoutValidationStatus;
|
||||
accepted?: boolean;
|
||||
inferenceCredibleRange?: readonly [string, string] | null;
|
||||
engineAcceptAllowed?: boolean;
|
||||
engineProposeAllowed?: boolean;
|
||||
engineCeiling: EngineCapabilityCeiling;
|
||||
datedMethodCollectOpen?: boolean;
|
||||
/** Server-persisted inference counters; never infer these from chat text. */
|
||||
inferenceRounds?: number;
|
||||
@@ -111,28 +165,57 @@ export type DecideRectificationInput = Readonly<{
|
||||
userUncertaintyHigh?: boolean;
|
||||
}>;
|
||||
|
||||
function evidenceStopReason(
|
||||
function classifyStop(
|
||||
input: DecideRectificationInput,
|
||||
separation: CandidateSeparation,
|
||||
): EvidenceStopReason | null {
|
||||
if (separation.ranked.length === 0) return null;
|
||||
): StopClass | null {
|
||||
if (
|
||||
input.datedEventCount !== undefined
|
||||
&& input.datedEventCount < MIN_STANDALONE_DATED_EVENTS
|
||||
) {
|
||||
return "insufficient_dated_events";
|
||||
return { kind: "keep_collecting", reason: "insufficient_dated_events" };
|
||||
}
|
||||
if (
|
||||
input.datedDomainCount !== undefined
|
||||
&& input.datedDomainCount < MIN_STANDALONE_DATED_DOMAINS
|
||||
) {
|
||||
return "insufficient_domains";
|
||||
return { kind: "keep_collecting", reason: "insufficient_domains" };
|
||||
}
|
||||
if (separation.ranked.length === 0 && !input.discriminatorProbe) {
|
||||
return { kind: "keep_collecting", reason: "insufficient_dated_events" };
|
||||
}
|
||||
if (input.userStopped === true) return { kind: "user_stopped" };
|
||||
if (separation.tiedForFirst) return { kind: "exhausted", reason: "tied_first" };
|
||||
if (input.userUncertaintyHigh === true) {
|
||||
return { kind: "exhausted", reason: "user_uncertainty_too_high" };
|
||||
}
|
||||
if (separation.tiedForFirst) return "tied_first";
|
||||
if (input.userUncertaintyHigh === true) return "user_uncertainty_too_high";
|
||||
return null;
|
||||
}
|
||||
|
||||
function deliveryCapability(input: {
|
||||
stopClass: StopClass | null;
|
||||
separation: CandidateSeparation;
|
||||
holdout: HoldoutValidationStatus;
|
||||
engineCeiling: EngineCapabilityCeiling;
|
||||
confirmationAllowed: boolean;
|
||||
coverageBlocks: boolean;
|
||||
}): DeliveryCapability {
|
||||
const locallySelectable = input.separation.ranked.length > 0
|
||||
&& !input.coverageBlocks
|
||||
&& input.stopClass?.kind !== "keep_collecting"
|
||||
&& input.stopClass?.kind !== "exhausted"
|
||||
&& input.separation.sufficient
|
||||
&& input.holdout === "passed";
|
||||
return {
|
||||
canAdopt: locallySelectable && input.engineCeiling.acceptanceAllowed,
|
||||
selectionAllowed: locallySelectable && input.engineCeiling.selectionAllowed,
|
||||
proposeAllowed: locallySelectable && input.engineCeiling.proposeAllowed,
|
||||
canConfirmExactMinute: locallySelectable
|
||||
&& input.confirmationAllowed
|
||||
&& input.engineCeiling.confirmationAllowed,
|
||||
};
|
||||
}
|
||||
|
||||
export function decideRectification(input: DecideRectificationInput): RectificationDecision {
|
||||
const separation = evaluateCandidateSeparation(input.candidateScores);
|
||||
const probe = input.discriminatorProbe ?? null;
|
||||
@@ -144,9 +227,63 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
|
||||
?? (separation.representativeTime
|
||||
? [separation.representativeTime, separation.representativeTime] as const
|
||||
: null);
|
||||
const stopClass = classifyStop(input, separation);
|
||||
const stopReason = stopClass && "reason" in stopClass ? stopClass.reason : null;
|
||||
const canDiscriminateDespiteCoverage = Boolean(probe)
|
||||
&& !separation.sufficient
|
||||
&& input.trainingGateOpen !== false;
|
||||
const coverageBlocks = (!input.methodCoverageAll && !canDiscriminateDespiteCoverage)
|
||||
|| input.trainingGateOpen === false;
|
||||
const capability = deliveryCapability({
|
||||
stopClass,
|
||||
separation,
|
||||
holdout,
|
||||
engineCeiling: input.engineCeiling,
|
||||
confirmationAllowed,
|
||||
coverageBlocks,
|
||||
});
|
||||
|
||||
const stopReason = evidenceStopReason(input, separation);
|
||||
|
||||
if (input.snapshotCurrent === false) {
|
||||
if (probe && !userStopped && input.trainingGateOpen !== false) {
|
||||
return discriminateOrExhaust(input, separation, holdout, range, probe, capability, stopReason);
|
||||
}
|
||||
return collect(separation, holdout, range, probe, capability, stopReason);
|
||||
}
|
||||
if (userStopped && separation.ranked.length > 0) {
|
||||
return completeWithRange(separation, holdout, range, "user_stopped", capability);
|
||||
}
|
||||
if (coverageBlocks) {
|
||||
const engineOffers = input.engineCeiling.acceptanceAllowed
|
||||
|| input.engineCeiling.proposeAllowed;
|
||||
if (
|
||||
stopClass?.kind !== "keep_collecting"
|
||||
&& input.trainingGateOpen !== false
|
||||
&& separation.ranked.length > 0
|
||||
&& !probe
|
||||
&& engineOffers
|
||||
&& input.datedMethodCollectOpen !== true
|
||||
) {
|
||||
return offerRangeWithoutAdopt(separation, holdout, range, capability);
|
||||
}
|
||||
return collect(separation, holdout, range, probe, capability, stopReason);
|
||||
}
|
||||
if (stopClass?.kind === "keep_collecting") {
|
||||
return collect(separation, holdout, range, probe, capability, stopClass.reason);
|
||||
}
|
||||
if (stopClass?.kind === "exhausted" && stopClass.reason === "user_uncertainty_too_high") {
|
||||
return completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason);
|
||||
}
|
||||
if (!separation.sufficient) {
|
||||
if (probe) {
|
||||
return discriminateOrExhaust(input, separation, holdout, range, probe, capability, stopReason);
|
||||
}
|
||||
return stopClass?.kind === "exhausted"
|
||||
? completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason)
|
||||
: completeWithRange(separation, holdout, range, "offer", capability);
|
||||
}
|
||||
if (stopClass?.kind === "exhausted") {
|
||||
return completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason);
|
||||
}
|
||||
if (input.accepted) {
|
||||
return finish(confirmationAllowed ? "awaiting_confirmation" : "adopt_representative", {
|
||||
input,
|
||||
@@ -154,7 +291,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
|
||||
holdout,
|
||||
range,
|
||||
probe: null,
|
||||
canConfirmExactMinute: confirmationAllowed,
|
||||
capability,
|
||||
});
|
||||
}
|
||||
if (confirmationAllowed) {
|
||||
@@ -164,78 +301,20 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
|
||||
holdout,
|
||||
range,
|
||||
probe: null,
|
||||
canConfirmExactMinute: true,
|
||||
capability,
|
||||
});
|
||||
}
|
||||
if (userStopped && input.candidateScores.length > 0) {
|
||||
return completeWithRange(separation, holdout, range, "user_stopped");
|
||||
}
|
||||
if (input.snapshotCurrent === false) {
|
||||
if (probe && !userStopped && input.trainingGateOpen !== false) {
|
||||
return discriminateOrExhaust(
|
||||
input,
|
||||
separation,
|
||||
holdout,
|
||||
range,
|
||||
probe,
|
||||
);
|
||||
}
|
||||
if (!(userStopped && input.candidateScores.length > 0)) {
|
||||
return collect(separation, holdout, range, probe);
|
||||
}
|
||||
}
|
||||
if (stopReason) {
|
||||
return completeWithRange(separation, holdout, range, "exhausted", stopReason);
|
||||
}
|
||||
const canDiscriminateDespiteCoverage = Boolean(probe)
|
||||
&& !separation.sufficient
|
||||
&& input.trainingGateOpen !== false;
|
||||
const coverageBlocks = (!input.methodCoverageAll && !canDiscriminateDespiteCoverage)
|
||||
|| input.trainingGateOpen === false;
|
||||
if (coverageBlocks) {
|
||||
const trainingOpen = input.trainingGateOpen !== false;
|
||||
const engineOffers = input.engineAcceptAllowed === true || input.engineProposeAllowed === true;
|
||||
if (
|
||||
trainingOpen
|
||||
&& input.candidateScores.length > 0
|
||||
&& !probe
|
||||
&& engineOffers
|
||||
&& input.datedMethodCollectOpen !== true
|
||||
) {
|
||||
return offerRangeWithoutAdopt(separation, holdout, range);
|
||||
}
|
||||
return collect(separation, holdout, range, probe);
|
||||
}
|
||||
if (!separation.sufficient) {
|
||||
if (probe && !userStopped) {
|
||||
return discriminateOrExhaust(input, separation, holdout, range, probe);
|
||||
}
|
||||
return completeWithRange(separation, holdout, range, userStopped ? "user_stopped" : "offer");
|
||||
}
|
||||
if (holdout === "not_started" && !userStopped) {
|
||||
return holdoutValidation(separation, range);
|
||||
if (holdout === "not_started") {
|
||||
return holdoutValidation(separation, range, capability);
|
||||
}
|
||||
if (holdout === "failed") {
|
||||
if (probe && !userStopped) {
|
||||
return discriminateOrExhaust(input, separation, holdout, range, probe);
|
||||
if (probe) {
|
||||
return discriminateOrExhaust(input, separation, holdout, range, probe, capability);
|
||||
}
|
||||
return completeWithRange(separation, holdout, range, "exhausted");
|
||||
return completeWithRange(separation, holdout, range, "exhausted", capability);
|
||||
}
|
||||
if (holdout === "unavailable") {
|
||||
if (userStopped) {
|
||||
return completeWithRange(separation, holdout, range, "user_stopped");
|
||||
}
|
||||
return finish("adopt_representative", {
|
||||
input,
|
||||
separation,
|
||||
holdout,
|
||||
range,
|
||||
probe: null,
|
||||
canConfirmExactMinute: false,
|
||||
});
|
||||
}
|
||||
if (userStopped && holdout !== "passed") {
|
||||
return completeWithRange(separation, holdout, range, "user_stopped");
|
||||
return offerRangeWithoutAdopt(separation, holdout, range, capability);
|
||||
}
|
||||
return finish("adopt_representative", {
|
||||
input,
|
||||
@@ -243,7 +322,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
|
||||
holdout,
|
||||
range,
|
||||
probe: null,
|
||||
canConfirmExactMinute: false,
|
||||
capability,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -259,11 +338,13 @@ function discriminateOrExhaust(
|
||||
holdout: HoldoutValidationStatus,
|
||||
range: readonly [string, string] | null,
|
||||
probe: CandidateDiscriminatorProbe,
|
||||
capability: DeliveryCapability,
|
||||
stopReason: EvidenceStopReason | null = null,
|
||||
): RectificationDecision {
|
||||
if (budgetExhausted(input)) {
|
||||
return completeWithRange(separation, holdout, range, "exhausted");
|
||||
return completeWithRange(separation, holdout, range, "exhausted", capability, stopReason);
|
||||
}
|
||||
return discriminate(separation, holdout, range, probe);
|
||||
return discriminate(separation, holdout, range, probe, capability, stopReason);
|
||||
}
|
||||
|
||||
function collect(
|
||||
@@ -271,6 +352,8 @@ function collect(
|
||||
holdout: HoldoutValidationStatus,
|
||||
range: readonly [string, string] | null,
|
||||
probe: CandidateDiscriminatorProbe | null,
|
||||
capability: DeliveryCapability,
|
||||
stopReason: EvidenceStopReason | null = null,
|
||||
): RectificationDecision {
|
||||
return {
|
||||
phase: "event_collection",
|
||||
@@ -278,10 +361,7 @@ function collect(
|
||||
sessionOutcome: "collect_evidence",
|
||||
resultStatus: "insufficient_evidence",
|
||||
canOfferRange: false,
|
||||
canAdopt: false,
|
||||
canConfirmExactMinute: false,
|
||||
selectionAllowed: false,
|
||||
proposeAllowed: false,
|
||||
...capability,
|
||||
precisionStage: "collect_events",
|
||||
activeFocusPolicy: "keep",
|
||||
completionStatus: null,
|
||||
@@ -292,6 +372,7 @@ function collect(
|
||||
probe,
|
||||
holdoutValidation: holdout,
|
||||
droppedProbes: [],
|
||||
stopReason,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -299,6 +380,7 @@ function offerRangeWithoutAdopt(
|
||||
separation: CandidateSeparation,
|
||||
holdout: HoldoutValidationStatus,
|
||||
range: readonly [string, string] | null,
|
||||
capability: DeliveryCapability,
|
||||
): RectificationDecision {
|
||||
return {
|
||||
phase: "discrimination",
|
||||
@@ -306,10 +388,7 @@ function offerRangeWithoutAdopt(
|
||||
sessionOutcome: "provisional_range",
|
||||
resultStatus: "insufficient_evidence",
|
||||
canOfferRange: true,
|
||||
canAdopt: false,
|
||||
canConfirmExactMinute: false,
|
||||
selectionAllowed: false,
|
||||
proposeAllowed: false,
|
||||
...capability,
|
||||
precisionStage: "theme_refine",
|
||||
activeFocusPolicy: "close",
|
||||
completionStatus: null,
|
||||
@@ -361,6 +440,8 @@ function discriminate(
|
||||
holdout: HoldoutValidationStatus,
|
||||
range: readonly [string, string] | null,
|
||||
probe: CandidateDiscriminatorProbe,
|
||||
capability: DeliveryCapability,
|
||||
stopReason: EvidenceStopReason | null = null,
|
||||
): RectificationDecision {
|
||||
return {
|
||||
phase: "discrimination",
|
||||
@@ -368,10 +449,7 @@ function discriminate(
|
||||
sessionOutcome: "discriminate_candidates",
|
||||
resultStatus: "discriminating",
|
||||
canOfferRange: false,
|
||||
canAdopt: false,
|
||||
canConfirmExactMinute: false,
|
||||
selectionAllowed: false,
|
||||
proposeAllowed: false,
|
||||
...capability,
|
||||
precisionStage: "theme_refine",
|
||||
activeFocusPolicy: "keep",
|
||||
completionStatus: null,
|
||||
@@ -382,12 +460,14 @@ function discriminate(
|
||||
probe,
|
||||
holdoutValidation: holdout,
|
||||
droppedProbes: [],
|
||||
...(stopReason ? { stopReason } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function holdoutValidation(
|
||||
separation: CandidateSeparation,
|
||||
range: readonly [string, string] | null,
|
||||
capability: DeliveryCapability,
|
||||
): RectificationDecision {
|
||||
return {
|
||||
phase: "holdout_validation",
|
||||
@@ -395,10 +475,7 @@ function holdoutValidation(
|
||||
sessionOutcome: "validate_holdout",
|
||||
resultStatus: "discriminating",
|
||||
canOfferRange: false,
|
||||
canAdopt: false,
|
||||
canConfirmExactMinute: false,
|
||||
selectionAllowed: false,
|
||||
proposeAllowed: false,
|
||||
...capability,
|
||||
precisionStage: "theme_refine",
|
||||
activeFocusPolicy: "keep",
|
||||
completionStatus: null,
|
||||
@@ -417,6 +494,7 @@ function completeWithRange(
|
||||
holdout: HoldoutValidationStatus,
|
||||
range: readonly [string, string] | null,
|
||||
kind: "user_stopped" | "offer" | "exhausted",
|
||||
capability: DeliveryCapability,
|
||||
stopReason: EvidenceStopReason | null = null,
|
||||
): RectificationDecision {
|
||||
const userStopped = kind === "user_stopped";
|
||||
@@ -433,10 +511,7 @@ function completeWithRange(
|
||||
sessionOutcome,
|
||||
resultStatus: "completed_with_range",
|
||||
canOfferRange: true,
|
||||
canAdopt: true,
|
||||
canConfirmExactMinute: false,
|
||||
selectionAllowed: true,
|
||||
proposeAllowed: true,
|
||||
...capability,
|
||||
precisionStage: "ready_to_adopt",
|
||||
activeFocusPolicy: "close",
|
||||
completionStatus: userStopped ? "provisional_range_user_stopped" : null,
|
||||
@@ -460,17 +535,17 @@ function finish(
|
||||
holdout: HoldoutValidationStatus;
|
||||
range: readonly [string, string] | null;
|
||||
probe: CandidateDiscriminatorProbe | null;
|
||||
canConfirmExactMinute: boolean;
|
||||
capability: DeliveryCapability;
|
||||
},
|
||||
): RectificationDecision {
|
||||
const completionStatus: CompletionStatus | null = input.canConfirmExactMinute && input.input.accepted
|
||||
const completionStatus: CompletionStatus | null = input.capability.canConfirmExactMinute && input.input.accepted
|
||||
? "exact_minute_confirmed"
|
||||
: input.holdout === "passed"
|
||||
? "validated_range"
|
||||
: input.input.userStopped === true
|
||||
? "provisional_range_user_stopped"
|
||||
: null;
|
||||
const kind = input.canConfirmExactMinute
|
||||
const kind = input.capability.canConfirmExactMinute
|
||||
? (input.input.accepted ? "exact_minute_confirmed" : "awaiting_confirmation")
|
||||
: input.holdout === "passed"
|
||||
? "validated_range"
|
||||
@@ -483,10 +558,7 @@ function finish(
|
||||
? "converged"
|
||||
: "completed_with_range",
|
||||
canOfferRange: true,
|
||||
canAdopt: true,
|
||||
canConfirmExactMinute: input.canConfirmExactMinute,
|
||||
selectionAllowed: true,
|
||||
proposeAllowed: true,
|
||||
...input.capability,
|
||||
precisionStage: "ready_to_adopt",
|
||||
activeFocusPolicy: "close",
|
||||
completionStatus,
|
||||
|
||||
@@ -679,7 +679,7 @@ ${nextInterview.hostNarration}`
|
||||
nextInterviewPersisted = true;
|
||||
}
|
||||
}
|
||||
const adoptionNarration = nextAction.type === "offer_provisional_range" && nextAction.can_adopt
|
||||
const adoptionNarration = nextAction.can_offer_range && nextAction.can_adopt
|
||||
? `${nonConvergingRangeNarration({
|
||||
credibleRange: nextAction.credible_range,
|
||||
representativeTime: nextAction.representative_time,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "../core/candidate-contrast-packet.ts";
|
||||
import {
|
||||
decideRectification,
|
||||
engineCapabilityCeilingFromReceipt,
|
||||
publicDecisionFields,
|
||||
type HoldoutValidationStatus,
|
||||
type RectificationDecision,
|
||||
@@ -522,9 +523,7 @@ export function decideFromDossier(
|
||||
holdoutValidation: holdoutStatusFromInference(inference, oosBlindPrompts),
|
||||
accepted: Boolean(dossier.case.acceptedTime),
|
||||
inferenceCredibleRange: inference?.credible_range ?? null,
|
||||
engineAcceptAllowed: latest?.selectionAllowed === true
|
||||
|| latest?.decisionReceipt?.accept_allowed === true,
|
||||
engineProposeAllowed: latest?.decisionReceipt?.propose_allowed === true,
|
||||
engineCeiling: engineCapabilityCeilingFromReceipt(latest?.decisionReceipt ?? null),
|
||||
datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods),
|
||||
...decisionBudget,
|
||||
...evidenceStops,
|
||||
@@ -553,6 +552,7 @@ export function decideAfterInferenceChange(input: {
|
||||
trainingGateOpen: trainingScoreableGate(input.dossier.evidence).open,
|
||||
candidateScores: [],
|
||||
userStopped: input.userStopped,
|
||||
engineCeiling: engineCapabilityCeilingFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null),
|
||||
...decisionBudgetFromInference(null),
|
||||
...evidenceStops,
|
||||
});
|
||||
@@ -593,9 +593,7 @@ export function decideAfterInferenceChange(input: {
|
||||
inferenceCredibleRange: input.state.credible_range,
|
||||
userStopped: input.userStopped,
|
||||
accepted: Boolean(input.dossier.case.acceptedTime),
|
||||
engineAcceptAllowed: input.dossier.latestResult?.selectionAllowed === true
|
||||
|| input.dossier.latestResult?.decisionReceipt?.accept_allowed === true,
|
||||
engineProposeAllowed: input.dossier.latestResult?.decisionReceipt?.propose_allowed === true,
|
||||
engineCeiling: engineCapabilityCeilingFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null),
|
||||
datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods),
|
||||
...decisionBudgetFromInference(input.state),
|
||||
...evidenceStops,
|
||||
@@ -616,9 +614,22 @@ export function overlayPublicDecision<T extends object>(
|
||||
const fields = publicDecisionFields(decision);
|
||||
const projection = authoritativeCandidateProjection(snapshot as DecisionDossier["latestResult"]);
|
||||
const inconsistent = !projection.consistent;
|
||||
const ceiling = engineCapabilityCeilingFromReceipt(
|
||||
(snapshot as DecisionDossier["latestResult"])?.decisionReceipt ?? null,
|
||||
);
|
||||
const canAdopt = !inconsistent && fields.can_adopt && ceiling.acceptanceAllowed;
|
||||
const selectionAllowed = !inconsistent && fields.selection_allowed && ceiling.selectionAllowed;
|
||||
const proposeAllowed = !inconsistent && fields.propose_allowed && ceiling.proposeAllowed;
|
||||
const canConfirmExactMinute = !inconsistent
|
||||
&& fields.can_confirm_exact_minute
|
||||
&& ceiling.confirmationAllowed;
|
||||
return {
|
||||
...snapshot,
|
||||
...fields,
|
||||
can_adopt: canAdopt,
|
||||
selection_allowed: selectionAllowed,
|
||||
propose_allowed: proposeAllowed,
|
||||
can_confirm_exact_minute: canConfirmExactMinute,
|
||||
...(projection.fromInference
|
||||
? {
|
||||
candidates: projection.candidates,
|
||||
@@ -628,8 +639,7 @@ export function overlayPublicDecision<T extends object>(
|
||||
credible_range: projection.credibleRange,
|
||||
}
|
||||
: {}),
|
||||
...(inconsistent ? { can_adopt: false, selection_allowed: false } : {}),
|
||||
selectionAllowed: inconsistent ? false : fields.selection_allowed,
|
||||
selectionAllowed,
|
||||
validated: fields.validated,
|
||||
completionStatus: fields.completion_status,
|
||||
};
|
||||
|
||||
@@ -70,7 +70,9 @@ export type V9EngineScoreResult = Readonly<{
|
||||
candidates: readonly V9EngineCandidate[];
|
||||
overallConfidence: "low" | "medium" | "high";
|
||||
marginPercent: number | null;
|
||||
acceptanceAllowed: boolean;
|
||||
selectionAllowed: boolean;
|
||||
proposeAllowed: boolean;
|
||||
confirmationAllowed: boolean;
|
||||
representativeCandidateId: string | null;
|
||||
representativeTime: string | null;
|
||||
@@ -237,7 +239,9 @@ type ParsedReceipt = Readonly<{
|
||||
raw: V9DecisionReceipt;
|
||||
eventContractVersion: string;
|
||||
policyVersion: string;
|
||||
acceptanceAllowed: boolean;
|
||||
selectionAllowed: boolean;
|
||||
proposeAllowed: boolean;
|
||||
confirmationAllowed: boolean;
|
||||
representativeCandidateId: string | null;
|
||||
representativeTime: string | null;
|
||||
@@ -267,6 +271,7 @@ function readDecisionReceipt(value: unknown, candidates: readonly V9EngineCandid
|
||||
|| typeof row.display_allowed !== "boolean"
|
||||
|| typeof row.selection_allowed !== "boolean"
|
||||
|| typeof row.acceptance_allowed !== "boolean"
|
||||
|| typeof row.propose_allowed !== "boolean"
|
||||
|| typeof row.confirmation_allowed !== "boolean"
|
||||
|| typeof row.accept_allowed !== "boolean"
|
||||
|| typeof row.confirm_allowed !== "boolean"
|
||||
@@ -288,7 +293,6 @@ function readDecisionReceipt(value: unknown, candidates: readonly V9EngineCandid
|
||||
|| (representativeCandidateId !== null && (!representative || representative.time !== representativeTime))
|
||||
|| (row.confirmation_allowed === true && row.selection_allowed !== true)
|
||||
|| (row.selection_allowed === true && row.display_allowed !== true)
|
||||
|| (row.propose_allowed !== undefined && typeof row.propose_allowed !== "boolean")
|
||||
|| (row.propose_allowed === true && row.selection_allowed !== true)
|
||||
|| !questionContractVersionIsCompatible(row.question_contract_version ?? row.question_contract)
|
||||
) {
|
||||
@@ -298,7 +302,9 @@ function readDecisionReceipt(value: unknown, candidates: readonly V9EngineCandid
|
||||
raw: row,
|
||||
eventContractVersion: EVENT_CONTRACT_VERSION,
|
||||
policyVersion: row.policy_version,
|
||||
acceptanceAllowed: row.acceptance_allowed,
|
||||
selectionAllowed: row.selection_allowed,
|
||||
proposeAllowed: row.propose_allowed,
|
||||
confirmationAllowed: row.confirmation_allowed,
|
||||
representativeCandidateId,
|
||||
representativeTime,
|
||||
@@ -527,7 +533,9 @@ export async function runV9CandidateScore(input: {
|
||||
candidates,
|
||||
overallConfidence: receipt.overallConfidence,
|
||||
marginPercent: receipt.marginPercent,
|
||||
acceptanceAllowed: receipt.acceptanceAllowed,
|
||||
selectionAllowed: receipt.selectionAllowed,
|
||||
proposeAllowed: receipt.proposeAllowed,
|
||||
confirmationAllowed: receipt.confirmationAllowed,
|
||||
representativeCandidateId: receipt.representativeCandidateId,
|
||||
representativeTime: receipt.representativeTime,
|
||||
|
||||
@@ -1139,8 +1139,14 @@ export function decideConversationalSession(input: {
|
||||
: discriminatorFromFollowup(input.nextFollowup),
|
||||
holdoutValidation: input.holdoutValidation,
|
||||
accepted: input.accepted,
|
||||
engineAcceptAllowed: input.selectionAllowed,
|
||||
engineProposeAllowed: input.proposeAllowed,
|
||||
// This helper is not used by the production V9 delivery path; keep its
|
||||
// delivery ceiling explicit without coupling it to question selection.
|
||||
engineCeiling: {
|
||||
acceptanceAllowed: input.selectionAllowed === true,
|
||||
selectionAllowed: input.selectionAllowed === true,
|
||||
proposeAllowed: input.proposeAllowed === true,
|
||||
confirmationAllowed: input.confirmationAllowed === true,
|
||||
},
|
||||
datedMethodCollectOpen: input.methods ? datedMethodCollectOpen(input.methods) : undefined,
|
||||
inferenceRounds: input.inferenceRounds,
|
||||
effectiveAnswerCount: input.effectiveAnswerCount,
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
userFacingRunFailure,
|
||||
isIncompleteRunBanner,
|
||||
} from "../src/lib/rectification-agentic/v9/run-diagnostic.ts";
|
||||
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
|
||||
import { applyHoldoutAnswer, buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
|
||||
import { RECTIFICATION_TERMINATION_COPY } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
|
||||
import { parseV9CaseDossier, RectificationToolServiceError } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import {
|
||||
@@ -425,11 +425,11 @@ function familyCollectDossier() {
|
||||
}
|
||||
|
||||
function adoptionInference() {
|
||||
return buildInferenceState({
|
||||
return applyHoldoutAnswer(buildInferenceState({
|
||||
range_start: "04:45",
|
||||
range_end: "05:15",
|
||||
candidates: [
|
||||
{ id: "05:00", time: "05:00", relative_support: 16 },
|
||||
{ id: "05:00", time: "05:00", relative_support: 18 },
|
||||
{ id: "05:10", time: "05:10", relative_support: 14 },
|
||||
],
|
||||
events: [
|
||||
@@ -439,7 +439,7 @@ function adoptionInference() {
|
||||
{ id: "e4", domain: "relationship", year: 2024, precision: "day" },
|
||||
],
|
||||
probes: [RELOCATION_2015_PROBE],
|
||||
});
|
||||
}), "yes");
|
||||
}
|
||||
|
||||
function adoptionDossier() {
|
||||
@@ -466,7 +466,13 @@ function adoptionDossier() {
|
||||
latestResult: candidateSnapshotFixture({
|
||||
selectionAllowed: true,
|
||||
representativeTime: "05:00",
|
||||
decisionReceipt: { inference_state: inference },
|
||||
decisionReceipt: {
|
||||
acceptance_allowed: true,
|
||||
selection_allowed: true,
|
||||
propose_allowed: true,
|
||||
confirmation_allowed: false,
|
||||
inference_state: inference,
|
||||
},
|
||||
}),
|
||||
conversationSummary: conversationSummaryFixture({
|
||||
declinedSkippedTopics: [{ target_domain: "family", status: "skipped" }],
|
||||
@@ -598,7 +604,8 @@ test("last structured choice emits the adoption range and persists the same narr
|
||||
optionId: "A",
|
||||
expectedRevision: adoptionInference().revision,
|
||||
});
|
||||
assert.equal(applied.nextAction.type, "offer_provisional_range");
|
||||
// Formerly offer_provisional_range: that depended on a 16/14 unresolved fixture being adoptable.
|
||||
assert.equal(applied.nextAction.type, "ready_to_adopt");
|
||||
assert.equal(applied.nextAction.can_adopt, true);
|
||||
assert.match(applied.narration, /当前可信区间是/);
|
||||
assert.match(applied.narration, /代表分钟/);
|
||||
|
||||
@@ -820,9 +820,11 @@ function liveCaseDossier(): DecisionDossier {
|
||||
})),
|
||||
representativeTime: state.representative_time,
|
||||
decisionReceipt: {
|
||||
acceptance_allowed: true,
|
||||
accept_allowed: true,
|
||||
propose_allowed: true,
|
||||
selection_allowed: true,
|
||||
confirmation_allowed: false,
|
||||
inference_state: state,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { PUBLIC_RECTIFICATION_TOOLS } from "../src/lib/rectification-agentic/v9/public-receipt.ts";
|
||||
import { safeToolErrorCode, RectificationToolServiceError } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import {
|
||||
OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
CASE_ID,
|
||||
CANDIDATE_ID,
|
||||
RESULT_ID,
|
||||
@@ -138,6 +139,7 @@ const PLATEAU = [
|
||||
|
||||
function collectDecision(candidates: readonly { time: string; relativeSupport: number }[]) {
|
||||
return decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: false,
|
||||
trainingGateOpen: false,
|
||||
candidateScores: candidates.map((item) => ({ time: item.time, score: item.relativeSupport })),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support.ts";
|
||||
|
||||
import { EFFECTIVE_ANSWER_SAFETY_CAP } from "../src/lib/birth-time-dynamic-stop-policy.ts";
|
||||
import {
|
||||
DEFAULT_MAX_DISCRIMINATION_ROUNDS,
|
||||
@@ -50,6 +52,7 @@ const PROBE: CandidateDiscriminatorProbe = {
|
||||
};
|
||||
|
||||
const BASE_INPUT = {
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: true,
|
||||
trainingGateOpen: true,
|
||||
candidateScores: [
|
||||
@@ -165,7 +168,8 @@ test("exhausted discrimination still delivers the credible candidate range", ()
|
||||
assert.equal(decision.resultStatus, "completed_with_range");
|
||||
assert.equal(decision.sessionOutcome, "completed_with_range");
|
||||
assert.equal(decision.canOfferRange, true);
|
||||
assert.equal(decision.canAdopt, true);
|
||||
// 原值是 true;候选未分开且区分预算耗尽只能交付 review-only range,不能采用。
|
||||
assert.equal(decision.canAdopt, false);
|
||||
assert.deepEqual(decision.credibleRange, ["05:00", "05:07"]);
|
||||
});
|
||||
|
||||
@@ -198,19 +202,24 @@ test("additional score evidence never widens the credible range", () => {
|
||||
assert.ok(width(after.credibleRange) <= width(before.credibleRange));
|
||||
});
|
||||
|
||||
test("evidence-state stops complete with a usable range and the fixed termination copy", () => {
|
||||
test("insufficient standalone evidence keeps collecting", () => {
|
||||
for (const item of [
|
||||
{ reason: "insufficient_dated_events" as const, input: { datedEventCount: 2, datedDomainCount: 2 } },
|
||||
{ reason: "insufficient_domains" as const, input: { datedEventCount: 3, datedDomainCount: 1 } },
|
||||
]) {
|
||||
const decision = decideRectification({ ...BASE_INPUT, ...item.input });
|
||||
assert.equal(decision.nextAction, "ask_fact_collection");
|
||||
assert.equal(decision.sessionOutcome, "collect_evidence");
|
||||
assert.equal(decision.canOfferRange, false);
|
||||
assert.equal(decision.stopReason, item.reason);
|
||||
}
|
||||
});
|
||||
|
||||
test("exhausted evidence-state stops complete with a usable range and the fixed termination copy", () => {
|
||||
const cases: readonly Readonly<{
|
||||
reason: EvidenceStopReason;
|
||||
input: Partial<Parameters<typeof decideRectification>[0]>;
|
||||
}>[] = [
|
||||
{
|
||||
reason: "insufficient_dated_events",
|
||||
input: { datedEventCount: 2, datedDomainCount: 2 },
|
||||
},
|
||||
{
|
||||
reason: "insufficient_domains",
|
||||
input: { datedEventCount: 3, datedDomainCount: 1 },
|
||||
},
|
||||
{
|
||||
reason: "tied_first",
|
||||
input: {
|
||||
@@ -221,6 +230,7 @@ test("evidence-state stops complete with a usable range and the fixed terminatio
|
||||
{ time: "05:06", score: 34 },
|
||||
{ time: "05:07", score: 32 },
|
||||
],
|
||||
discriminatorProbe: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -393,11 +403,13 @@ test("dossier and post-inference decisions share the half-uncertain stop rule",
|
||||
|
||||
test("rectification label ladder follows evidence state and explicit adapter support", () => {
|
||||
const blocked = decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: false,
|
||||
trainingGateOpen: false,
|
||||
candidateScores: [],
|
||||
});
|
||||
assert.equal(rectificationLabel({ decision: blocked }), "blocked");
|
||||
// 原值是 blocked;空候选现在被分类为证据不足,不能继续无原因地 fail-open。
|
||||
assert.equal(rectificationLabel({ decision: blocked }), "user_history_verification_required");
|
||||
|
||||
const stopped = decideRectification({
|
||||
...BASE_INPUT,
|
||||
|
||||
@@ -94,7 +94,13 @@ function producedSnapshot(
|
||||
candidates: persisted,
|
||||
representativeTime: state.representative_time,
|
||||
evidenceLedgerFingerprint: evidenceLedgerFingerprint(COVERAGE_EVIDENCE as never),
|
||||
decisionReceipt: { inference_state: state },
|
||||
decisionReceipt: {
|
||||
acceptance_allowed: true,
|
||||
selection_allowed: true,
|
||||
propose_allowed: true,
|
||||
confirmation_allowed: false,
|
||||
inference_state: state,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -128,7 +134,7 @@ test("producer 58/42 lead keeps both candidates and can close without opening th
|
||||
assert.notEqual(decision.sessionOutcome, "exact_minute_confirmed");
|
||||
});
|
||||
|
||||
test("producer 58/42 with no holdout is ready_to_adopt, not a unique minute", () => {
|
||||
test("producer 58/42 with no holdout remains a review-only range", () => {
|
||||
const { latest } = producedSnapshot([
|
||||
{ time: "05:02", relativeSupport: 58 },
|
||||
{ time: "04:55", relativeSupport: 42 },
|
||||
@@ -136,7 +142,9 @@ test("producer 58/42 with no holdout is ready_to_adopt, not a unique minute", ()
|
||||
const decision = decideFromDossier(dossierFor(latest));
|
||||
assert.equal(authoritativeCandidateProjection(latest).consistent, true);
|
||||
assert.equal(decision.separation.sufficient, true);
|
||||
assert.equal(decision.nextAction, "ready_to_adopt");
|
||||
// 原值是 ready_to_adopt;没有 holdout 不能进入采用态,只能展示 review-only range。
|
||||
assert.equal(decision.nextAction, "offer_provisional_range");
|
||||
assert.equal(decision.canAdopt, false);
|
||||
assert.equal(decision.canConfirmExactMinute, false);
|
||||
assert.notEqual(decision.sessionOutcome, "exact_minute_confirmed");
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support.ts";
|
||||
|
||||
import {
|
||||
askedEventProbeKeysFromLedgerEvidence,
|
||||
buildCandidateContrastPacket,
|
||||
@@ -128,12 +130,14 @@ test("unseparated scores still discriminate when training is open and a probe ex
|
||||
});
|
||||
assert.equal(next.type, "ask_candidate_discriminator");
|
||||
assert.equal(decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: false,
|
||||
trainingGateOpen: true,
|
||||
candidateScores: TIED,
|
||||
discriminatorProbe: CONTRAST_PROBE,
|
||||
}).sessionOutcome, "discriminate_candidates");
|
||||
assert.equal(decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: false,
|
||||
trainingGateOpen: true,
|
||||
candidateScores: [],
|
||||
@@ -302,6 +306,7 @@ test("user stop skips remaining collection and holdout when candidates already e
|
||||
});
|
||||
assert.equal(separated.type, "complete_with_range");
|
||||
const stopped = decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: false,
|
||||
userStopped: true,
|
||||
candidateScores: [
|
||||
@@ -364,6 +369,7 @@ test("discrimination waits for three training events after reserving holdout", (
|
||||
|
||||
test("holdout passed is a validated range, not a user-stop close", () => {
|
||||
const validated = decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: true,
|
||||
candidateScores: [
|
||||
{ time: "04:48", score: 58 },
|
||||
@@ -473,6 +479,7 @@ test("stale snapshot keeps discrimination instead of returning to collection", (
|
||||
});
|
||||
assert.equal(next.type, "ask_candidate_discriminator");
|
||||
assert.equal(decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: true,
|
||||
trainingGateOpen: true,
|
||||
snapshotCurrent: false,
|
||||
@@ -494,12 +501,15 @@ test("a single candidate is sole_candidate, not a parallel range", () => {
|
||||
assert.equal(separation.lead, 0);
|
||||
assert.deepEqual(separation.credibleRange, ["05:00"]);
|
||||
const decision = decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: true,
|
||||
trainingGateOpen: true,
|
||||
candidateScores: [{ time: "05:00", score: 80 }],
|
||||
holdoutValidation: "unavailable",
|
||||
});
|
||||
assert.equal(decision.sessionOutcome, "adopt_representative");
|
||||
// 原值是 adopt_representative;holdout unavailable 且用户未停止只能提供 review-only range。
|
||||
assert.equal(decision.sessionOutcome, "provisional_range");
|
||||
assert.equal(decision.canAdopt, false);
|
||||
assert.equal(decision.canConfirmExactMinute, false);
|
||||
assert.doesNotMatch(
|
||||
buildSkillVerificationReport({
|
||||
|
||||
@@ -4,6 +4,7 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
decideRectification,
|
||||
engineCapabilityCeilingFromReceipt,
|
||||
publicDecisionFields,
|
||||
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
|
||||
import { decideNextAction } from "../src/lib/rectification-agentic/core/decide-next-action.ts";
|
||||
@@ -29,6 +30,182 @@ const SEPARATED = [
|
||||
{ time: "04:49", score: 42 },
|
||||
];
|
||||
|
||||
type TestEngineCapabilityCeiling = Readonly<{
|
||||
acceptanceAllowed: boolean;
|
||||
selectionAllowed: boolean;
|
||||
proposeAllowed: boolean;
|
||||
confirmationAllowed: boolean;
|
||||
}>;
|
||||
|
||||
const ENGINE_OPEN: TestEngineCapabilityCeiling = {
|
||||
acceptanceAllowed: true,
|
||||
selectionAllowed: true,
|
||||
proposeAllowed: true,
|
||||
confirmationAllowed: true,
|
||||
};
|
||||
|
||||
function capabilityCombinations(): TestEngineCapabilityCeiling[] {
|
||||
return Array.from({ length: 16 }, (_, bits) => ({
|
||||
acceptanceAllowed: Boolean(bits & 1),
|
||||
selectionAllowed: Boolean(bits & 2),
|
||||
proposeAllowed: Boolean(bits & 4),
|
||||
confirmationAllowed: Boolean(bits & 8),
|
||||
}));
|
||||
}
|
||||
|
||||
function decideWithEngineCeiling(
|
||||
engineCeiling: TestEngineCapabilityCeiling,
|
||||
overrides: Partial<Parameters<typeof decideRectification>[0]> = {},
|
||||
) {
|
||||
return decideRectification({
|
||||
methodCoverageAll: true,
|
||||
trainingGateOpen: true,
|
||||
snapshotCurrent: true,
|
||||
candidateScores: SEPARATED,
|
||||
holdoutValidation: "passed",
|
||||
confirmationAllowed: true,
|
||||
datedEventCount: 3,
|
||||
datedDomainCount: 2,
|
||||
...overrides,
|
||||
engineCeiling,
|
||||
} as Parameters<typeof decideRectification>[0]);
|
||||
}
|
||||
|
||||
test("invariant 1: TypeScript delivery capabilities never exceed the engine ceiling", () => {
|
||||
for (const ceiling of capabilityCombinations()) {
|
||||
const decision = decideWithEngineCeiling(ceiling);
|
||||
assert.equal(decision.canAdopt && !ceiling.acceptanceAllowed, false, JSON.stringify(ceiling));
|
||||
assert.equal(decision.selectionAllowed && !ceiling.selectionAllowed, false, JSON.stringify(ceiling));
|
||||
assert.equal(decision.proposeAllowed && !ceiling.proposeAllowed, false, JSON.stringify(ceiling));
|
||||
assert.equal(decision.canConfirmExactMinute && !ceiling.confirmationAllowed, false, JSON.stringify(ceiling));
|
||||
}
|
||||
});
|
||||
|
||||
test("invariant 2: insufficient dated evidence never permits selection or adoption", () => {
|
||||
for (const datedEventCount of [0, 2, 3]) {
|
||||
for (const datedDomainCount of [0, 1, 2]) {
|
||||
if (datedEventCount >= 3 && datedDomainCount >= 2) continue;
|
||||
const decision = decideWithEngineCeiling(ENGINE_OPEN, {
|
||||
confirmationAllowed: false,
|
||||
datedEventCount,
|
||||
datedDomainCount,
|
||||
});
|
||||
assert.equal(decision.canAdopt, false, `${datedEventCount} events / ${datedDomainCount} domains`);
|
||||
assert.equal(decision.selectionAllowed, false, `${datedEventCount} events / ${datedDomainCount} domains`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("invariant 3: tied leading candidates remain review-only when the user stops", () => {
|
||||
const tiedScores = [
|
||||
{ time: "04:48", score: 12 },
|
||||
{ time: "04:49", score: 12 },
|
||||
{ time: "04:50", score: 11 },
|
||||
];
|
||||
const stopped = decideWithEngineCeiling(ENGINE_OPEN, {
|
||||
candidateScores: tiedScores,
|
||||
confirmationAllowed: false,
|
||||
userStopped: true,
|
||||
});
|
||||
assert.equal(stopped.completionStatus, "provisional_range_user_stopped");
|
||||
assert.equal(stopped.canAdopt, false);
|
||||
|
||||
const continuing = decideWithEngineCeiling(ENGINE_OPEN, {
|
||||
candidateScores: tiedScores,
|
||||
confirmationAllowed: false,
|
||||
userStopped: false,
|
||||
});
|
||||
assert.equal(continuing.separation.tiedForFirst, true);
|
||||
assert.equal(continuing.canAdopt, false);
|
||||
});
|
||||
|
||||
test("invariant 4: unavailable holdout stays review-only whether or not the user stops", () => {
|
||||
for (const userStopped of [false, true]) {
|
||||
const decision = decideWithEngineCeiling(ENGINE_OPEN, {
|
||||
confirmationAllowed: false,
|
||||
holdoutValidation: "unavailable",
|
||||
userStopped,
|
||||
});
|
||||
assert.equal(decision.canAdopt, false, `userStopped=${userStopped}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("raw engine receipt contradictions fail closed before delivery", () => {
|
||||
const openReceipt = {
|
||||
acceptance_allowed: true,
|
||||
selection_allowed: true,
|
||||
propose_allowed: true,
|
||||
confirmation_allowed: false,
|
||||
};
|
||||
const closed = {
|
||||
acceptanceAllowed: false,
|
||||
selectionAllowed: false,
|
||||
proposeAllowed: false,
|
||||
confirmationAllowed: false,
|
||||
};
|
||||
for (const receipt of [
|
||||
{ ...openReceipt, accept_allowed: false },
|
||||
{ ...openReceipt, confirm_allowed: true },
|
||||
{ ...openReceipt, display_allowed: false },
|
||||
{ ...openReceipt, confirmation_allowed: undefined },
|
||||
]) {
|
||||
assert.deepEqual(engineCapabilityCeilingFromReceipt(receipt), closed);
|
||||
}
|
||||
});
|
||||
|
||||
test("invariant 5: public overlay intersects all delivery gates with the engine receipt", () => {
|
||||
const decision = decideWithEngineCeiling(ENGINE_OPEN);
|
||||
const candidates = [
|
||||
{ candidateId: "c-0507", time: "05:07", rank: 1, relativeSupport: 13, tiedMinuteCount: 1 },
|
||||
{ candidateId: "c-0500", time: "05:00", rank: 2, relativeSupport: 12, tiedMinuteCount: 1 },
|
||||
{ candidateId: "c-0515", time: "05:15", rank: 3, relativeSupport: 10, tiedMinuteCount: 1 },
|
||||
];
|
||||
const inferenceState = {
|
||||
algorithm_version: "rectification-inference-v1",
|
||||
candidate_set_id: "05:00-05:15:05:00,05:07,05:15",
|
||||
revision: 2,
|
||||
phase: "discrimination",
|
||||
result_status: "discriminating",
|
||||
range_start: "05:00",
|
||||
range_end: "05:15",
|
||||
candidates: [
|
||||
{ id: "05:07", time: "05:07", cluster_range: ["05:07", "05:07"], prior_score: 13, posterior_score: 15, probability: 0.4, status: "active", rank: 2, strong_conflict_count: 0 },
|
||||
{ id: "05:00", time: "05:00", cluster_range: ["05:00", "05:00"], prior_score: 12, posterior_score: 20, probability: 0.6, status: "active", rank: 1, strong_conflict_count: 0 },
|
||||
{ id: "05:15", time: "05:15", cluster_range: ["05:15", "05:15"], prior_score: 10, posterior_score: 30, probability: 0, status: "eliminated", rank: 3, strong_conflict_count: 1 },
|
||||
],
|
||||
events: [],
|
||||
probes: [],
|
||||
answered_probes: [],
|
||||
rounds: [],
|
||||
entropy: 0.67,
|
||||
representative_time: "05:00",
|
||||
credible_range: ["05:00", "05:07"],
|
||||
};
|
||||
const validEngineCeilings = capabilityCombinations().filter((ceiling) => (
|
||||
ceiling.acceptanceAllowed === ceiling.selectionAllowed
|
||||
&& (!ceiling.proposeAllowed || ceiling.selectionAllowed)
|
||||
&& (!ceiling.confirmationAllowed || ceiling.selectionAllowed)
|
||||
));
|
||||
|
||||
for (const ceiling of validEngineCeilings) {
|
||||
const overlaid = overlayPublicDecision({
|
||||
candidates,
|
||||
representativeTime: "05:07",
|
||||
decisionReceipt: {
|
||||
acceptance_allowed: ceiling.acceptanceAllowed,
|
||||
selection_allowed: ceiling.selectionAllowed,
|
||||
propose_allowed: ceiling.proposeAllowed,
|
||||
confirmation_allowed: ceiling.confirmationAllowed,
|
||||
inference_state: inferenceState,
|
||||
},
|
||||
}, decision);
|
||||
assert.equal(overlaid.can_adopt, ceiling.acceptanceAllowed, JSON.stringify(ceiling));
|
||||
assert.equal(overlaid.selection_allowed, ceiling.selectionAllowed, JSON.stringify(ceiling));
|
||||
assert.equal(overlaid.propose_allowed, ceiling.proposeAllowed, JSON.stringify(ceiling));
|
||||
assert.equal(overlaid.can_confirm_exact_minute, ceiling.confirmationAllowed, JSON.stringify(ceiling));
|
||||
}
|
||||
});
|
||||
|
||||
function readSource(relative: string) {
|
||||
return readFileSync(new URL(relative, import.meta.url), "utf8");
|
||||
}
|
||||
@@ -63,7 +240,7 @@ test("public decision fields are derived from decideRectification", () => {
|
||||
},
|
||||
];
|
||||
for (const input of fixtures) {
|
||||
const decision = decideRectification(input);
|
||||
const decision = decideRectification({ ...input, engineCeiling: ENGINE_OPEN });
|
||||
const fields = publicDecisionFields(decision);
|
||||
assert.deepEqual(fields, {
|
||||
type: decision.nextAction,
|
||||
@@ -518,6 +695,7 @@ test("career and relationship training still discriminates before family or occu
|
||||
|
||||
test("public candidate cards follow the inference ranking and hide an inconsistent state", () => {
|
||||
const decision = decideRectification({
|
||||
engineCeiling: ENGINE_OPEN,
|
||||
methodCoverageAll: true,
|
||||
trainingGateOpen: true,
|
||||
candidateScores: SEPARATED,
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
resolveExactSkillPackage,
|
||||
} from "../src/lib/skill-package-registry.ts";
|
||||
import {
|
||||
OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
CASE_ID,
|
||||
CANDIDATE_ID,
|
||||
FOCUS_ID,
|
||||
@@ -55,6 +56,7 @@ const UNIQUE_MINUTE_COPY = /±5 分钟确定性/;
|
||||
|
||||
function collectDecision(candidates: readonly { time: string; relativeSupport?: number }[]) {
|
||||
return decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: false,
|
||||
trainingGateOpen: false,
|
||||
candidateScores: candidates.map((item) => ({ time: item.time, score: item.relativeSupport ?? 0 })),
|
||||
@@ -198,6 +200,7 @@ const ENGINE_SCORE = {
|
||||
display_allowed: true,
|
||||
selection_allowed: true,
|
||||
acceptance_allowed: true,
|
||||
propose_allowed: true,
|
||||
confirmation_allowed: false,
|
||||
accept_allowed: true,
|
||||
confirm_allowed: false,
|
||||
@@ -856,6 +859,7 @@ test("read-case follows method plan and keeps D9/D10 type tables when SQL missin
|
||||
policy_version: "rectification-candidate-policy-v2",
|
||||
selection_allowed: true,
|
||||
acceptance_allowed: true,
|
||||
propose_allowed: true,
|
||||
confirmation_allowed: false,
|
||||
representative_candidate_id: CANDIDATE_ID,
|
||||
overall_confidence: "medium",
|
||||
@@ -1373,6 +1377,15 @@ test("public tool surface stays at 14 and new cases bind 10.0.13", () => {
|
||||
selectionKind: null,
|
||||
algorithmVersion: "rectification-v5",
|
||||
decisionReceipt: {
|
||||
acceptance_allowed: true,
|
||||
selection_allowed: true,
|
||||
propose_allowed: true,
|
||||
confirmation_allowed: false,
|
||||
inference_state: producedInferenceState(plateauCandidates.map((item) => ({
|
||||
id: item.candidateId,
|
||||
time: item.time,
|
||||
relative_support: item.relativeSupport,
|
||||
}))),
|
||||
execution_ledger: [
|
||||
{ method: "vimshottari-dasha", status: "executed" },
|
||||
{ method: "narayana-dasha", status: "executed" },
|
||||
@@ -1389,7 +1402,7 @@ test("public tool surface stays at 14 and new cases bind 10.0.13", () => {
|
||||
// The execution ledger does not prove same-range support or raw-result agreement.
|
||||
assert.equal(plateau.rectification_label, "manual_pattern_consensus");
|
||||
assert.deepEqual(plateau.executed_methods, ["vimshottari-dasha", "narayana-dasha"]);
|
||||
const completed = latestResultToolProjection({
|
||||
const collecting = latestResultToolProjection({
|
||||
resultId: RESULT_ID,
|
||||
candidates: plateauCandidates,
|
||||
selectionAllowed: true,
|
||||
@@ -1405,6 +1418,7 @@ test("public tool surface stays at 14 and new cases bind 10.0.13", () => {
|
||||
],
|
||||
},
|
||||
}, decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: false,
|
||||
trainingGateOpen: false,
|
||||
datedEventCount: 2,
|
||||
@@ -1414,10 +1428,41 @@ test("public tool surface stays at 14 and new cases bind 10.0.13", () => {
|
||||
score: item.relativeSupport,
|
||||
})),
|
||||
}));
|
||||
assert.equal(completed.rectification_label, "user_history_verification_required");
|
||||
assert.equal(completed.evidence_stop_reason, "insufficient_dated_events");
|
||||
assert.equal(collecting.rectification_label, "user_history_verification_required");
|
||||
assert.equal(collecting.evidence_stop_reason, "insufficient_dated_events");
|
||||
assert.deepEqual(collecting.executed_methods, ["vimshottari-dasha", "narayana-dasha"]);
|
||||
|
||||
const exhaustedCandidates = plateauCandidates.map((item, index) => ({
|
||||
...item,
|
||||
relativeSupport: index < 2 ? 40 : 20,
|
||||
}));
|
||||
const completed = latestResultToolProjection({
|
||||
resultId: RESULT_ID,
|
||||
candidates: exhaustedCandidates,
|
||||
selectionAllowed: true,
|
||||
confirmationAllowed: false,
|
||||
representativeTime: "04:45",
|
||||
selectedTime: null,
|
||||
selectionKind: null,
|
||||
algorithmVersion: "rectification-v5",
|
||||
decisionReceipt: {
|
||||
acceptance_allowed: true,
|
||||
selection_allowed: true,
|
||||
propose_allowed: true,
|
||||
confirmation_allowed: false,
|
||||
},
|
||||
}, decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: true,
|
||||
trainingGateOpen: true,
|
||||
datedEventCount: 3,
|
||||
datedDomainCount: 2,
|
||||
candidateScores: exhaustedCandidates.map((item) => ({
|
||||
time: item.time,
|
||||
score: item.relativeSupport,
|
||||
})),
|
||||
}));
|
||||
assert.equal(completed.termination_copy, "当前最优结果是候选时间段,而不是已经确认的唯一出生分钟。临时代表时间仅用于下一轮验证与比较。");
|
||||
assert.deepEqual(completed.executed_methods, ["vimshottari-dasha", "narayana-dasha"]);
|
||||
const skill = readFileSync(new URL("../../skills/jyotish-birth-time-rectification/SKILL.md", import.meta.url), "utf8");
|
||||
assert.match(skill, /method_followup_plan/);
|
||||
assert.match(skill, /感情 → 事业 → 家人 → 职业 → 占问/);
|
||||
@@ -1435,12 +1480,12 @@ test("public tool surface stays at 14 and new cases bind 10.0.13", () => {
|
||||
|
||||
test("Mastra hides active candidates when the receipt range is corrupted", () => {
|
||||
const candidates = [
|
||||
{ candidateId: CANDIDATE_ID, time: "05:00", rank: 1, relativeSupport: 20, tiedMinuteCount: 1 },
|
||||
{ candidateId: SECOND_CANDIDATE_ID, time: "05:07", rank: 2, relativeSupport: 15, tiedMinuteCount: 1 },
|
||||
{ candidateId: CANDIDATE_ID, time: "05:00", rank: 1, relativeSupport: 58, tiedMinuteCount: 1 },
|
||||
{ candidateId: SECOND_CANDIDATE_ID, time: "05:07", rank: 2, relativeSupport: 42, tiedMinuteCount: 1 },
|
||||
];
|
||||
const inferenceState = producedInferenceState([
|
||||
{ id: CANDIDATE_ID, time: "05:00", relative_support: 20 },
|
||||
{ id: SECOND_CANDIDATE_ID, time: "05:07", relative_support: 15 },
|
||||
{ id: CANDIDATE_ID, time: "05:00", relative_support: 58 },
|
||||
{ id: SECOND_CANDIDATE_ID, time: "05:07", relative_support: 42 },
|
||||
]);
|
||||
const latest = {
|
||||
resultId: RESULT_ID,
|
||||
@@ -1451,9 +1496,16 @@ test("Mastra hides active candidates when the receipt range is corrupted", () =>
|
||||
selectedTime: null,
|
||||
selectionKind: null,
|
||||
algorithmVersion: "rectification-v5",
|
||||
decisionReceipt: { inference_state: inferenceState },
|
||||
decisionReceipt: {
|
||||
acceptance_allowed: true,
|
||||
selection_allowed: true,
|
||||
propose_allowed: true,
|
||||
confirmation_allowed: false,
|
||||
inference_state: inferenceState,
|
||||
},
|
||||
};
|
||||
const session = decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: true,
|
||||
userStopped: true,
|
||||
candidateScores: candidates.map((item) => ({ time: item.time, score: item.relativeSupport })),
|
||||
@@ -1468,6 +1520,7 @@ test("Mastra hides active candidates when the receipt range is corrupted", () =>
|
||||
const invalid = latestResultToolProjection({
|
||||
...latest,
|
||||
decisionReceipt: {
|
||||
...latest.decisionReceipt,
|
||||
inference_state: { ...inferenceState, credible_range: ["04:00", "04:10"] },
|
||||
},
|
||||
}, session);
|
||||
@@ -2896,11 +2949,15 @@ test("paused case with selection_allowed may offer the escape hatch", async () =
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({
|
||||
status: "paused",
|
||||
evidence: [educationEvidence],
|
||||
evidence: [educationEvidence, familyEvidence, careerEvidence],
|
||||
latestResult: candidateSnapshotFixture({
|
||||
selectionAllowed: true,
|
||||
representativeTime: "04:48",
|
||||
evidenceLedgerFingerprint: scoreableFingerprintForRawEvidence([educationEvidence]),
|
||||
evidenceLedgerFingerprint: scoreableFingerprintForRawEvidence([
|
||||
educationEvidence,
|
||||
familyEvidence,
|
||||
careerEvidence,
|
||||
]),
|
||||
candidates: [
|
||||
{ candidate_id: CANDIDATE_ID, rank: 1, time: "04:48", relative_support: 58, tied_minute_count: 1 },
|
||||
{ candidate_id: SECOND_CANDIDATE_ID, rank: 2, time: "04:49", relative_support: 42, tied_minute_count: 2 },
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support.ts";
|
||||
|
||||
import { applyChoiceWithoutEvidence } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
|
||||
import { applyHoldoutAnswer, buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
|
||||
import { unionStillValidRange } from "../src/lib/rectification-agentic/core/credible-range.ts";
|
||||
@@ -155,6 +157,7 @@ test("hidden case walks collection through holdout to a range or representative
|
||||
score: item.posterior_score,
|
||||
}));
|
||||
const afterAnswers = decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: true,
|
||||
candidateScores: scores,
|
||||
holdoutValidation: "not_started",
|
||||
@@ -166,6 +169,7 @@ test("hidden case walks collection through holdout to a range or representative
|
||||
assert.equal(holdoutPassed.holdout_passed, true);
|
||||
assert.deepEqual(posteriorMap(holdoutPassed.candidates), posteriorMap(answered.state.candidates));
|
||||
const closed = decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: true,
|
||||
candidateScores: holdoutPassed.candidates.map((item) => ({
|
||||
time: item.time,
|
||||
@@ -223,6 +227,7 @@ test("holdout failure returns to candidate discrimination", () => {
|
||||
}],
|
||||
});
|
||||
const next = decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: true,
|
||||
candidateScores: failed.candidates.map((item) => ({ time: item.time, score: item.posterior_score })),
|
||||
discriminatorProbe: selectDiscriminatorProbe(retryPacket),
|
||||
@@ -277,6 +282,7 @@ test("mutated years and domains do not keep asking exam-quality copy", () => {
|
||||
|
||||
test("user stop is an unvalidated range, not a holdout pass", () => {
|
||||
const stopped = decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: true,
|
||||
userStopped: true,
|
||||
candidateScores: [
|
||||
@@ -293,6 +299,7 @@ test("user stop is an unvalidated range, not a holdout pass", () => {
|
||||
|
||||
test("tied candidates without a high-information probe offer a range instead of inventing left/right", () => {
|
||||
const next = decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: true,
|
||||
trainingGateOpen: true,
|
||||
candidateScores: [
|
||||
@@ -378,6 +385,7 @@ test("case A: two then three events keep collecting; four training events may di
|
||||
})),
|
||||
});
|
||||
return decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: true,
|
||||
trainingGateOpen: state.events.filter((item) => item.usage === "training").length >= 3
|
||||
&& new Set(state.events.filter((item) => item.usage === "training").map((item) => item.domain)).size >= 2,
|
||||
|
||||
@@ -97,7 +97,13 @@ function producedDossier(
|
||||
})),
|
||||
representativeTime: state.representative_time,
|
||||
evidenceLedgerFingerprint: evidenceLedgerFingerprint(evidence as never),
|
||||
decisionReceipt: { inference_state: state },
|
||||
decisionReceipt: {
|
||||
acceptance_allowed: true,
|
||||
selection_allowed: true,
|
||||
propose_allowed: true,
|
||||
confirmation_allowed: false,
|
||||
inference_state: state,
|
||||
},
|
||||
};
|
||||
return {
|
||||
state,
|
||||
@@ -121,7 +127,7 @@ function holdoutFollowup(dossier: ReturnType<typeof producedDossier>["dossier"])
|
||||
});
|
||||
}
|
||||
|
||||
test("sticky holdout with unknown precision does not ask validation and can adopt", () => {
|
||||
test("sticky holdout with unknown precision stays review-only", () => {
|
||||
const { state, dossier: dated } = producedDossier(COVERED_EVIDENCE);
|
||||
const holdout = state.events.find((item) => item.usage === "holdout");
|
||||
assert.ok(holdout);
|
||||
@@ -141,11 +147,14 @@ test("sticky holdout with unknown precision does not ask validation and can adop
|
||||
|
||||
const decision = decideFromDossier(dossier);
|
||||
assert.notEqual(decision.nextAction, "ask_holdout_validation");
|
||||
assert.equal(decision.nextAction, "ready_to_adopt");
|
||||
assert.equal(decision.sessionOutcome, "adopt_representative");
|
||||
// 原值是 ready_to_adopt;unknown precision 让 holdout 不可用,不能进入采用态。
|
||||
assert.equal(decision.nextAction, "offer_provisional_range");
|
||||
// 原值是 adopt_representative;未通过独立核对只能交付临时区间。
|
||||
assert.equal(decision.sessionOutcome, "provisional_range");
|
||||
assert.notEqual(decision.sessionOutcome, "validated_range");
|
||||
assert.equal(decision.validated, false);
|
||||
assert.equal(decision.canAdopt, true);
|
||||
// 原值是 true;holdout unavailable 必须 fail-closed。
|
||||
assert.equal(decision.canAdopt, false);
|
||||
assert.equal(decision.canConfirmExactMinute, false);
|
||||
});
|
||||
|
||||
@@ -169,7 +178,10 @@ test("passed holdout is a validated range, not a unique minute", () => {
|
||||
...dossier,
|
||||
latestResult: {
|
||||
...dossier.latestResult,
|
||||
decisionReceipt: { inference_state: passed },
|
||||
decisionReceipt: {
|
||||
...dossier.latestResult.decisionReceipt,
|
||||
inference_state: passed,
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(decided.sessionOutcome, "validated_range");
|
||||
@@ -187,7 +199,10 @@ test("failed holdout keeps existing non-validated close or discriminate path", (
|
||||
...dossier,
|
||||
latestResult: {
|
||||
...dossier.latestResult,
|
||||
decisionReceipt: { inference_state: failed },
|
||||
decisionReceipt: {
|
||||
...dossier.latestResult.decisionReceipt,
|
||||
inference_state: failed,
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(decided.holdoutValidation, "failed");
|
||||
|
||||
@@ -21,6 +21,7 @@ import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9
|
||||
import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
|
||||
import { createRectificationV9Tools, latestResultToolProjection } from "../src/mastra/rectification-v9-tools.ts";
|
||||
import {
|
||||
OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
CASE_ID,
|
||||
CANDIDATE_ID,
|
||||
EVIDENCE_ID,
|
||||
@@ -128,6 +129,7 @@ test("a 25-minute tied plateau projects width and forbids unique-minute confirma
|
||||
selectionKind: null,
|
||||
algorithmVersion: "rectification-v5",
|
||||
}, decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: false,
|
||||
trainingGateOpen: false,
|
||||
candidateScores: candidates.map((item) => ({ time: item.time, score: item.relativeSupport })),
|
||||
|
||||
@@ -2,6 +2,8 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support.ts";
|
||||
|
||||
import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
|
||||
import { trainingScoreableGate } from "../src/lib/rectification-agentic/v9/evidence-model.ts";
|
||||
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
|
||||
@@ -309,12 +311,11 @@ test("training gate closed still withholds yearless varga cards", () => {
|
||||
|
||||
test("training complete with no renderable distinguish card allows offering a range without adopt", () => {
|
||||
const blocked = decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: false,
|
||||
trainingGateOpen: true,
|
||||
candidateScores: NARROW_LEAD,
|
||||
discriminatorProbe: null,
|
||||
engineAcceptAllowed: true,
|
||||
engineProposeAllowed: true,
|
||||
});
|
||||
assert.equal(blocked.canOfferRange, true);
|
||||
assert.equal(blocked.canAdopt, false);
|
||||
@@ -322,12 +323,11 @@ test("training complete with no renderable distinguish card allows offering a ra
|
||||
assert.equal(blocked.nextAction, "offer_provisional_range");
|
||||
assert.equal(blocked.sessionOutcome, "provisional_range");
|
||||
const stillClosed = decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: false,
|
||||
trainingGateOpen: false,
|
||||
candidateScores: NARROW_LEAD,
|
||||
discriminatorProbe: null,
|
||||
engineAcceptAllowed: true,
|
||||
engineProposeAllowed: true,
|
||||
});
|
||||
assert.equal(stillClosed.canOfferRange, false);
|
||||
assert.equal(stillClosed.canAdopt, false);
|
||||
@@ -352,8 +352,10 @@ test("decideFromDossier offers a range when training is complete and the ask lay
|
||||
evidenceLedgerFingerprint: evidenceLedgerFingerprint(CASE_EVIDENCE as never),
|
||||
decisionReceipt: {
|
||||
accept_allowed: true,
|
||||
acceptance_allowed: true,
|
||||
propose_allowed: true,
|
||||
selection_allowed: true,
|
||||
confirmation_allowed: false,
|
||||
},
|
||||
},
|
||||
case: { acceptedTime: null },
|
||||
@@ -440,6 +442,7 @@ test("a mismatched receipt house_table is not emitted with another representativ
|
||||
|
||||
test("tool projection does not pair a 05:00 house table with a 04:47 representative time", () => {
|
||||
const decision = decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: true,
|
||||
trainingGateOpen: true,
|
||||
candidateScores: TIED,
|
||||
|
||||
@@ -32,6 +32,7 @@ import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn
|
||||
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
|
||||
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import {
|
||||
OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
CASE_ID,
|
||||
FOCUS_ID,
|
||||
TURN_ID,
|
||||
@@ -334,8 +335,10 @@ function liveDossier(
|
||||
representativeTime: "04:51",
|
||||
decisionReceipt: {
|
||||
accept_allowed: true,
|
||||
acceptance_allowed: true,
|
||||
propose_allowed: true,
|
||||
selection_allowed: true,
|
||||
confirmation_allowed: false,
|
||||
inference_state: state,
|
||||
...(extra.eventProbes
|
||||
? {
|
||||
@@ -401,8 +404,10 @@ function rpcDossier(decision: DecisionDossier) {
|
||||
candidates: snapshotCandidates(),
|
||||
decisionReceipt: {
|
||||
accept_allowed: true,
|
||||
acceptance_allowed: true,
|
||||
propose_allowed: true,
|
||||
selection_allowed: true,
|
||||
confirmation_allowed: false,
|
||||
...(decision.latestResult?.decisionReceipt ?? {}),
|
||||
},
|
||||
}),
|
||||
@@ -583,23 +588,24 @@ test("offerRangeWithoutAdopt persists a spoken collect and narrates the numeric
|
||||
assert.equal(Boolean(idle.hostNarration) && Boolean(question), true);
|
||||
});
|
||||
|
||||
test("userStopped still completes as provisional_range_user_stopped with canAdopt", () => {
|
||||
test("userStopped completes with a review-only provisional range", () => {
|
||||
const stopped = decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: false,
|
||||
trainingGateOpen: true,
|
||||
userStopped: true,
|
||||
engineAcceptAllowed: true,
|
||||
engineProposeAllowed: true,
|
||||
candidateScores: ACTIVE.map((time) => ({ time, score: SCORES[time] ?? 0 })),
|
||||
});
|
||||
assert.equal(stopped.sessionOutcome, "provisional_range_user_stopped");
|
||||
assert.equal(stopped.canAdopt, true);
|
||||
// 原值是 true;用户停止只结束追问,不能绕过 coverage 和 holdout 门槛。
|
||||
assert.equal(stopped.canAdopt, false);
|
||||
assert.equal(stopped.canOfferRange, true);
|
||||
assert.equal(isNonConvergingRangeOffer(stopped), false);
|
||||
const dossier = liveDossier([CAREER_2024_04, CAREER_2023_ACTIVATION], { status: "paused" });
|
||||
const fromDossier = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
assert.equal(fromDossier.sessionOutcome, "provisional_range_user_stopped");
|
||||
assert.equal(fromDossier.canAdopt, true);
|
||||
// 原值是 true;该 dossier 只有两个同域日期事件,停止不等于证据达标。
|
||||
assert.equal(fromDossier.canAdopt, false);
|
||||
});
|
||||
|
||||
test("idle persist still decides from the dossier once and does not invent collect_evidence", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PUBLIC_RECTIFICATION_TOOLS } from "../src/lib/rectification-agentic/v9/
|
||||
import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
|
||||
import { createRectificationV9AgentTools, createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
|
||||
import {
|
||||
OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
CASE_ID,
|
||||
EVIDENCE_ID,
|
||||
FOCUS_ID,
|
||||
@@ -212,6 +213,7 @@ test("agent tools omit offer-candidates when proposeAllowed is false", () => {
|
||||
turnId: TURN_ID,
|
||||
accounting: fakeAccounting({}).client as never,
|
||||
}, decideRectification({
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: false,
|
||||
trainingGateOpen: false,
|
||||
candidateScores: [],
|
||||
|
||||
@@ -25,6 +25,7 @@ const DECISION_RECEIPT = {
|
||||
display_allowed: true,
|
||||
selection_allowed: true,
|
||||
acceptance_allowed: true,
|
||||
propose_allowed: true,
|
||||
confirmation_allowed: false,
|
||||
accept_allowed: true,
|
||||
confirm_allowed: false,
|
||||
|
||||
@@ -53,6 +53,13 @@ export const SOURCE_TURN_ID = "77777777-7777-4777-8777-777777777777";
|
||||
export const RECTIFICATION_SKILL_SHA256 = "5acb3103e80993ea611b93d2c1746e70b74aff8f8636bcffc80a837b954b470d";
|
||||
export const RECTIFICATION_SKILL_SOURCE_COMMIT = "0fd111d16b45796086a6c1d0945dbd3de6755d8a";
|
||||
|
||||
export const OPEN_ENGINE_CAPABILITY_CEILING = {
|
||||
acceptanceAllowed: true,
|
||||
selectionAllowed: true,
|
||||
proposeAllowed: true,
|
||||
confirmationAllowed: true,
|
||||
} as const;
|
||||
|
||||
export const CANDIDATE_RANGE = { start_time: "04:50", end_time: "05:10" };
|
||||
|
||||
export function dossierFixture(overrides: {
|
||||
@@ -251,6 +258,7 @@ export function candidateSnapshotFixture(overrides: {
|
||||
policy_version: "rectification-candidate-policy-v2",
|
||||
selection_allowed: overrides.selectionAllowed ?? true,
|
||||
acceptance_allowed: overrides.selectionAllowed ?? true,
|
||||
propose_allowed: overrides.selectionAllowed ?? true,
|
||||
confirmation_allowed: overrides.confirmationAllowed ?? false,
|
||||
representative_candidate_id: overrides.representativeTime ? CANDIDATE_ID : null,
|
||||
overall_confidence: "medium",
|
||||
|
||||
Reference in New Issue
Block a user