From 564eede3409a7da94ebb0e0792590f731eb9ce4a Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 1 Oct 2026 11:47:04 +0800 Subject: [PATCH] fix(rectification): health distinguish focuses persist again; segment-gain order on by default for windows <= 21 minutes (BUG-1142, BUG-1143) BUG-1142: distinguish focuses wrote the planner domain verbatim, and health_pressure is not in the focus table's target_domain check. The write failed inside a swallowed retry and the session went straight to delivery although tap cards remained. Every focus write now goes through focusTargetDomain (persistableFocusDomain, BUG-672), and focus-to-probe matching uses sameCollectDomain. BUG-1143: segmentOrderEnabledFor turns segment-gain probe order on by default when the scanned window is at most 21 minutes, where the 77-case persisted replay gained head hits with no lost truth segment. RECTIFICATION_SEGMENT_ORDER =off disables it; =on keeps the 61-minute research envelope. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8 --- .../core/segment-probe-order.ts | 16 +++++++ .../v9/method-followup.ts | 6 +-- .../rectification-agentic/v9/score-persist.ts | 3 +- .../rectification-agentic/v9/server-focus.ts | 19 ++++++-- ...ation-focus-target-domain-20261001.test.ts | 43 +++++++++++++++++++ ...ion-segment-order-default-20261001.test.ts | 21 +++++++++ 6 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 frontend/tests/rectification-focus-target-domain-20261001.test.ts create mode 100644 frontend/tests/rectification-segment-order-default-20261001.test.ts diff --git a/frontend/src/lib/rectification-agentic/core/segment-probe-order.ts b/frontend/src/lib/rectification-agentic/core/segment-probe-order.ts index ac615a43..3f8d8d47 100644 --- a/frontend/src/lib/rectification-agentic/core/segment-probe-order.ts +++ b/frontend/src/lib/rectification-agentic/core/segment-probe-order.ts @@ -62,3 +62,19 @@ export function orderProbesBySegment(input: { return input.probes.map((probe, index) => ({ probe, index, gain: segmentInformationGain(probe, weights, input.segments, input.offsets) })) .sort((a, b) => b.gain - a.gain || a.index - b.index).map((row) => row.probe); } + +/** + * Production default for segment-gain probe order (BUG-1143). The 77-case persisted replay + * (2026-10-01) gained head hits with no lost truth segment at windows <= 21 minutes; at 61 + * minutes it lost truth segments, traced to the health-focus write failure (BUG-1142), so the + * default stays at 21 until that replay is rerun. "off" disables; "on" keeps the research + * envelope used by the persisted replay harness. + */ +export const SEGMENT_ORDER_DEFAULT_MAX_WINDOW = 21; +export const SEGMENT_ORDER_RESEARCH_MAX_WINDOW = 61; + +export function segmentOrderEnabledFor(windowMinutes: number | null | undefined, flag: string | undefined): boolean { + if (flag === "off" || !windowMinutes || windowMinutes <= 0) return false; + const limit = flag === "on" ? SEGMENT_ORDER_RESEARCH_MAX_WINDOW : SEGMENT_ORDER_DEFAULT_MAX_WINDOW; + return windowMinutes <= limit; +} diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 6ae1475c..2b586106 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -821,14 +821,14 @@ function liveDistinguishProbe( isValidDistinguishProbe({ ...probe, role: "distinguish" }) && (schemaKey ? probe.semantic_key === schemaKey - : (!focus.targetDomain || probe.domain === focus.targetDomain)) + : (!focus.targetDomain || sameCollectDomain(probe.domain, focus.targetDomain))) ); const fromEvents = (eventProbes ?? []).find(matchEvent) ?? null; if (fromEvents) return fromEvents; const matchContrast = (probe: CandidateDiscriminatorProbe) => ( schemaKey ? probe.semanticKey === schemaKey - : (!focus.targetDomain || probe.domain === focus.targetDomain) + : (!focus.targetDomain || sameCollectDomain(probe.domain, focus.targetDomain)) ); const fromContrast = (contrastProbes ?? []).find(matchContrast); if (!fromContrast) return null; @@ -2580,7 +2580,7 @@ export function buildMethodFollowupPlan(input: { : themeFromDomain ?? (focus.intent === "out_of_sample_check" ? "oos_blind" : "education_style") ) as MethodFollowup["ask_theme"]; const matchingVerify = (input.eventProbes ?? []).find((probe) => ( - probe.domain === focus.targetDomain + sameCollectDomain(probe.domain, focus.targetDomain) || REVERSE_VERIFY_THEME[probe.domain as keyof typeof REVERSE_VERIFY_THEME] === keepAskTheme )); const schema = focus.expectedAnswerSchema; diff --git a/frontend/src/lib/rectification-agentic/v9/score-persist.ts b/frontend/src/lib/rectification-agentic/v9/score-persist.ts index 7e215978..0bbcb18c 100644 --- a/frontend/src/lib/rectification-agentic/v9/score-persist.ts +++ b/frontend/src/lib/rectification-agentic/v9/score-persist.ts @@ -5,6 +5,7 @@ * write a fresh inference_state, but with an empty answer ledger. */ import { shouldForceMinuteAfterSubBlocks } from "./block-scan.ts"; +import { segmentOrderEnabledFor } from "../core/segment-probe-order.ts"; import { scanCaseSegments } from "./segment-scan.ts"; import { targetChartsForDomain } from "../core/segment-summary.ts"; import { buildProductCaseInferenceState } from "./product-inference.ts"; @@ -450,7 +451,7 @@ export async function scoreAndPersistCurrentEvidence(input: { : undefined; const inference = buildProductCaseInferenceState({ segmentMinutes, segmentTargets, segmentScanComplete: Boolean(segmentMinutes), - segmentOrderEnabled: process.env.RECTIFICATION_SEGMENT_ORDER === "on", + segmentOrderEnabled: segmentOrderEnabledFor(segmentMinutes?.length, process.env.RECTIFICATION_SEGMENT_ORDER), range: candidateRange, candidates: score.candidates, evidence: scorable, diff --git a/frontend/src/lib/rectification-agentic/v9/server-focus.ts b/frontend/src/lib/rectification-agentic/v9/server-focus.ts index b4310d99..0e89abf6 100644 --- a/frontend/src/lib/rectification-agentic/v9/server-focus.ts +++ b/frontend/src/lib/rectification-agentic/v9/server-focus.ts @@ -272,6 +272,20 @@ export function persistableFocusDomain(domain: string | null | undefined): strin return null; } +/** + * The domain a focus row may store. Every intent goes through persistableFocusDomain: + * the table only accepts education/career/relationship/relocation/finance/health/family/other, + * while planner domains include health_pressure and occupation (BUG-672). A raw + * health_pressure on a distinguish focus failed the check constraint, the write was + * swallowed and the session ended early (BUG-1142). + */ +export function focusTargetDomain(followup: Pick): string | null { + return persistableFocusDomain(followup.domain) + ?? (followup.intent === "collect_method_evidence" + ? persistableFocusDomain(collectQuestionDomain(followup.domain)) + : null); +} + function clampFocusTargetKind(kind: string | null | undefined): string | null { const value = kind?.trim() || ""; if (!value) return null; @@ -672,10 +686,7 @@ async function persistServerOwnedFocusCore(input: { questionId, intent: followup.intent, targetEvidenceId: followup.date_reliability_evidence_id ?? null, - targetDomain: followup.intent === "collect_method_evidence" - ? persistableFocusDomain(followup.domain) - ?? persistableFocusDomain(collectQuestionDomain(followup.domain)) - : followup.domain, + targetDomain: focusTargetDomain(followup), targetKind: followup.intent === "collect_method_evidence" ? collectFocusTargetKind(followup) : null, diff --git a/frontend/tests/rectification-focus-target-domain-20261001.test.ts b/frontend/tests/rectification-focus-target-domain-20261001.test.ts new file mode 100644 index 00000000..1deba380 --- /dev/null +++ b/frontend/tests/rectification-focus-target-domain-20261001.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { sameCollectDomain } from "../src/lib/rectification-agentic/v9/domain-alias.ts"; +import { focusTargetDomain } from "../src/lib/rectification-agentic/v9/server-focus.ts"; + +// BUG-1142: a distinguish focus wrote the planner domain verbatim. health_pressure is not in the +// focus table's target_domain check, the write failed inside a swallowed retry, and the session +// went straight to delivery although tap cards remained (BUG-672 alias family). +const migration = readFileSync(new URL("../supabase/migrations/20260814020000_rectification_v10_runtime.sql", import.meta.url), "utf8"); +const allowedBlock = /target_domain is null or target_domain in \(([^)]*)\)/.exec(migration); +assert.ok(allowedBlock, "focus table target_domain check is readable"); +const allowed = new Set([...allowedBlock[1]!.matchAll(/'([a-z_]+)'/g)].map((match) => match[1]!)); + +// Every domain the planner, engine catalog or collect pool can put on a followup. +const plannerDomains = [ + "education", "relocation", "relationship", "career", "finance", "health_pressure", "family", + "appearance", "occupation", "health", "other", "horary", "unknown", "active_focus", +]; + +test("every followup domain maps to a value the focus table accepts, for every intent", () => { + for (const intent of ["distinguish_candidates", "collect_method_evidence", "reverse_verify", "out_of_sample_check"] as const) { + for (const domain of plannerDomains) { + const stored = focusTargetDomain({ intent, domain } as never); + assert.ok(stored === null || allowed.has(stored), `${intent}/${domain} -> ${stored}`); + } + } +}); + +test("a health distinguish focus stores health and still matches health_pressure probes", () => { + assert.equal(focusTargetDomain({ intent: "distinguish_candidates", domain: "health_pressure" } as never), "health"); + assert.equal(focusTargetDomain({ intent: "distinguish_candidates", domain: "occupation" } as never), "other"); + assert.equal(focusTargetDomain({ intent: "distinguish_candidates", domain: "relationship" } as never), "relationship"); + assert.equal(sameCollectDomain("health_pressure", "health"), true); +}); + +test("focus writes and focus-to-probe matching never use a raw planner domain", () => { + const serverFocus = readFileSync(new URL("../src/lib/rectification-agentic/v9/server-focus.ts", import.meta.url), "utf8"); + assert.doesNotMatch(serverFocus, /:\s*followup\.domain,\s*\n/, "no raw followup.domain passed as targetDomain"); + assert.match(serverFocus, /targetDomain: focusTargetDomain\(followup\)/); + const followup = readFileSync(new URL("../src/lib/rectification-agentic/v9/method-followup.ts", import.meta.url), "utf8"); + assert.doesNotMatch(followup, /probe\.domain === focus\.targetDomain/); +}); diff --git a/frontend/tests/rectification-segment-order-default-20261001.test.ts b/frontend/tests/rectification-segment-order-default-20261001.test.ts new file mode 100644 index 00000000..a3fdf614 --- /dev/null +++ b/frontend/tests/rectification-segment-order-default-20261001.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { segmentOrderEnabledFor, SEGMENT_ORDER_DEFAULT_MAX_WINDOW } from "../src/lib/rectification-agentic/core/segment-probe-order.ts"; + +// BUG-1143: segment-gain order is on by default only where the 77-case persisted replay lost no truth segment. +test("segment order is on by default for windows up to 21 minutes and off beyond", () => { + assert.equal(SEGMENT_ORDER_DEFAULT_MAX_WINDOW, 21); + assert.equal(segmentOrderEnabledFor(21, undefined), true); + assert.equal(segmentOrderEnabledFor(11, ""), true); + assert.equal(segmentOrderEnabledFor(22, undefined), false); + assert.equal(segmentOrderEnabledFor(31, undefined), false); + assert.equal(segmentOrderEnabledFor(61, undefined), false); +}); + +test("off disables everywhere; on keeps the research envelope; no scan means off", () => { + assert.equal(segmentOrderEnabledFor(21, "off"), false); + assert.equal(segmentOrderEnabledFor(61, "on"), true); + assert.equal(segmentOrderEnabledFor(121, "on"), false); + assert.equal(segmentOrderEnabledFor(undefined, undefined), false); + assert.equal(segmentOrderEnabledFor(0, "on"), false); +});