feat: add VedAstro rectification post-validation

This commit is contained in:
Jesse_Chen
2026-07-30 11:48:02 +08:00
parent 2384faf367
commit 31e5709f33
13 changed files with 856 additions and 24 deletions
@@ -14,6 +14,7 @@ import { createRectificationV4CaseService } from "../src/lib/rectification-v4/ca
import type { CandidateEngineResult } from "../src/lib/rectification-v4/candidate-engine.ts";
import type {
CalculationSpec,
CandidateMinute,
LifeEventRevision,
RectificationAnalysisTrace,
RectificationV4Case,
@@ -24,9 +25,19 @@ import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/me
import { projectAnalysisMessages } from "../src/lib/rectification-v4/supabase-store.ts";
import type { ClaimedRectificationV4Job } from "../src/lib/rectification-v4/store.ts";
import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts";
import { v5EngineResult, withV5Mode } from "./rectification-v5-test-support.ts";
import { passingVedAstroValidation, v5EngineResult, withV5Mode } from "./rectification-v5-test-support.ts";
const now = "2026-07-29T00:00:00.000Z";
const rangeReadyCandidates: CandidateMinute[] = [
{ time: "05:13", score: 100, supportingEventIds: [], conflictingEventIds: [] },
{ time: "05:14", score: 99, supportingEventIds: [], conflictingEventIds: [] },
{ time: "05:15", score: 98, supportingEventIds: [], conflictingEventIds: [] },
{ time: "05:16", score: 60, supportingEventIds: [], conflictingEventIds: [] },
{ time: "05:17", score: 97.8, supportingEventIds: [], conflictingEventIds: [] },
{ time: "05:18", score: 97.7, supportingEventIds: [], conflictingEventIds: [] },
{ time: "05:19", score: 97.6, supportingEventIds: [], conflictingEventIds: [] },
];
const spec: CalculationSpec = {
version: "rectification-calculation-spec-v4",
birthDate: "1997-08-08",
@@ -258,7 +269,7 @@ test("scoring trace records one real engine call and only matrix-confirmed techn
engine: {
score: async ({ calculationSpec, events }) => {
scoreCalls += 1;
const base = v5EngineResult(calculationSpec, events);
const base = v5EngineResult(calculationSpec, events, rangeReadyCandidates);
const contributionMatrix: CandidateEngineResult["contributionMatrix"] = Object.fromEntries(
Object.entries(base.contributionMatrix).map(([eventId, candidates]) => [
eventId,
@@ -289,6 +300,87 @@ test("scoring trace records one real engine call and only matrix-confirmed techn
assert.deepEqual(analysisToolLabels(trace, "agent_diagnostic"), []);
});
test("VedAstro post-validation runs only after the local range gate and blocks publication on safe failure", async () => {
const claimed = makeClaimed([
event("education", "education_milestone", "离家去外地上大学", "2016-09"),
event("relocation", "relocation", "搬到北京长期居住", "2018-08"),
event("career", "career_change", "开始负责商业巡演公司", "2023-09"),
event("relationship", "relationship_start", "开始一段长期关系", "2021-05"),
event("finance", "finance_change", "收入结构发生明显变化", "2024-02"),
]);
let validationCalls = 0;
const blocked = await processRectificationAgentTurn({
claimed,
engine: {
score: async ({ calculationSpec, events }) => v5EngineResult(calculationSpec, events, rangeReadyCandidates),
validateWithVedAstro: async ({ candidateTimes }) => {
validationCalls += 1;
assert.deepEqual(candidateTimes, ["05:13", "05:14"]);
return passingVedAstroValidation(candidateTimes, {
status: "blocked",
providerStatus: "timeout",
blockers: ["vedastro_timeout"],
minuteSensitiveValidation: { comparisonReady: false, discriminated: false, discriminatedLayers: [] },
});
},
},
now: new Date(now),
});
assert.equal(validationCalls, 1);
assert.equal(blocked.snapshot?.canAcceptRange, false);
assert.equal(blocked.snapshot?.canConfirmExactMinute, false);
assert.ok(blocked.snapshot?.gateReasons.includes("vedastro_validation_not_passed"));
assert.equal(blocked.diagnostics?.externalValidation?.status, "blocked");
assert.deepEqual(analysisToolLabels(blocked.publicMessage.analysisTrace!, "diagnostic"), ["VedAstro 事后校验"]);
validationCalls = 0;
const localGateBlocked = await processRectificationAgentTurn({
claimed,
engine: {
score: async ({ calculationSpec, events }) => {
const result = v5EngineResult(calculationSpec, events, rangeReadyCandidates);
return { ...result, robustness: { ...result.robustness, leaveOneDomainOutRetentionRate: 0 } };
},
validateWithVedAstro: async ({ candidateTimes }) => {
validationCalls += 1;
return passingVedAstroValidation(candidateTimes);
},
},
now: new Date(now),
});
assert.equal(validationCalls, 0);
assert.equal(localGateBlocked.snapshot?.canAcceptRange, false);
assert.equal(localGateBlocked.diagnostics?.externalValidation, undefined);
});
test("legacy and shadow modes never call VedAstro post-validation", async () => {
const events = [
event("education", "education_milestone", "离家去外地上大学", "2016-09"),
event("relocation", "relocation", "搬到北京长期居住", "2018-08"),
event("career", "career_change", "开始负责商业巡演公司", "2023-09"),
event("relationship", "relationship_start", "开始一段长期关系", "2021-05"),
event("finance", "finance_change", "收入结构发生明显变化", "2024-02"),
];
for (const deploymentMode of ["v4_legacy", "v5_shadow"] as const) {
const base = makeClaimed(events);
let validationCalls = 0;
const result = await processRectificationAgentTurn({
claimed: { ...base, case: { ...base.case, deploymentMode } },
engine: {
score: async ({ calculationSpec, events: scoreEvents }) => v5EngineResult(calculationSpec, scoreEvents),
validateWithVedAstro: async ({ candidateTimes }) => {
validationCalls += 1;
return passingVedAstroValidation(candidateTimes);
},
},
now: new Date(now),
});
assert.equal(validationCalls, 0, deploymentMode);
assert.equal(result.diagnostics?.externalValidation, undefined, deploymentMode);
assert.equal(result.snapshot?.canConfirmExactMinute, false, deploymentMode);
}
});
test("read-only Agent diagnostics are traced only when the reasoner actually requests one", async () => {
const caseValue = makeClaimed([]).case;
const direct = await runBoundedReasoner({
@@ -87,3 +87,50 @@ test("candidate engine forwards present provenance without inventing missing fie
assert.equal(event.date_reliability, null);
assert.equal(Object.hasOwn(event, "date_corroboration"), false);
});
test("candidate engine sends only the selected pair and persists a safe VedAstro projection", async () => {
let body: Record<string, unknown> | null = null;
const engine = createRectificationV4CandidateEngine({
apiBase: "http://example.test",
fetchImpl: async (input, init) => {
assert.equal(String(input), "http://example.test/api/rectification/v5/vedastro-validate");
body = JSON.parse(String(init?.body)) as Record<string, unknown>;
return new Response(JSON.stringify({
status: "pass",
passed: true,
can_confirm_exact_minute: false,
candidate_times: { primary: "05:13", runner_up: "05:14" },
blockers: [],
minute_sensitive_validation: {
comparison_ready: true,
discriminated: true,
discriminated_layers: ["D9"],
raw_response: { secret: true },
},
event_validation: {
eligible_event_count: 1,
supported_event_count: 1,
unsupported_events: [{ event_id: "private", summary: "must not persist" }],
candidates: [
{ role: "primary", metric: { requested_event_count: 1, successful_event_count: 1, matched_event_count: 1, event_hit_count: 2, signal_lift: 3 }, events: [{ raw_response: "secret" }] },
{ role: "runner_up", metric: { requested_event_count: 1, successful_event_count: 1, matched_event_count: 1, event_hit_count: 1, signal_lift: 1 } },
],
},
raw_request: { api_key: "secret" },
}), { status: 200, headers: { "content-type": "application/json" } });
},
});
assert.ok(engine.validateWithVedAstro);
const validation = await engine.validateWithVedAstro({
calculationSpec: legacySpec,
events: [baseEvent],
candidateTimes: ["05:13", "05:14"],
});
const captured = body as unknown as Record<string, unknown>;
assert.deepEqual(captured.candidate_times, ["05:13", "05:14"]);
assert.equal(validation.status, "pass");
assert.equal(validation.canConfirmExactMinute, false);
assert.deepEqual(validation.minuteSensitiveValidation.discriminatedLayers, ["D9"]);
const serialized = JSON.stringify(validation);
assert.doesNotMatch(serialized, /raw_request|raw_response|api_key|must not persist|secret/);
});
@@ -6,7 +6,7 @@ import { createRectificationV4CaseService } from "../src/lib/rectification-v4/ca
import type { CalculationSpec, CandidateMinute } from "../src/lib/rectification-v4/contracts.ts";
import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/memory-store.ts";
import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts";
import { v5EngineResult, withV5Mode } from "./rectification-v5-test-support.ts";
import { passingVedAstroValidation, v5EngineResult, withV5Mode } from "./rectification-v5-test-support.ts";
const now = () => new Date("2026-07-26T08:00:00.000Z");
const spec: CalculationSpec = {
@@ -62,6 +62,9 @@ test("V5 golden replay persists the full artifact chain, returns ranges only, an
conflictingEventIds: candidate.score < 97 ? ids : [],
})));
},
async validateWithVedAstro({ candidateTimes }) {
return passingVedAstroValidation(candidateTimes);
},
},
});
const userId = randomUUID();
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import type { CandidateEngineResult } from "../src/lib/rectification-v4/candidate-engine.ts";
import { vedAstroPostValidationSchema, type VedAstroPostValidation } from "../src/lib/rectification-agent/contracts.ts";
import type { CalculationSpec, CandidateMinute, LifeEventRevision } from "../src/lib/rectification-v4/contracts.ts";
import { rectificationV4AlgorithmVersion } from "../src/lib/rectification-v4/contracts.ts";
import { calculationSpecHash } from "../src/lib/rectification-v4/fingerprints.ts";
@@ -72,6 +73,33 @@ export function v5EngineResult(
};
}
export function passingVedAstroValidation(
candidateTimes: readonly [string, string],
overrides: Partial<VedAstroPostValidation> = {},
): VedAstroPostValidation {
return vedAstroPostValidationSchema.parse({
contractVersion: "vedastro-post-validation-v1",
provider: "vedastro_official",
status: "pass",
providerStatus: "pass",
blockers: [],
primaryCandidateTime: candidateTimes[0],
runnerUpCandidateTime: candidateTimes[1],
eligibleEventCount: 5,
selectedEventCount: 3,
unsupportedEventCount: 0,
candidateMetrics: [
{ role: "primary", requestedEventCount: 3, successfulEventCount: 3, matchedEventCount: 3, eventHitCount: 5, signalLift: 4 },
{ role: "runner_up", requestedEventCount: 3, successfulEventCount: 3, matchedEventCount: 2, eventHitCount: 3, signalLift: 2 },
],
minuteSensitiveValidation: { comparisonReady: true, discriminated: true, discriminatedLayers: ["D9"] },
validationHash: "a".repeat(64),
validatedAt: "2026-07-30T00:00:00.000Z",
canConfirmExactMinute: false,
...overrides,
});
}
export async function withV5Mode<T>(mode: "v4_legacy" | "v5_shadow" | "v5_agent", run: () => Promise<T>): Promise<T> {
const keys = ["RECTIFICATION_AGENT_V5_ENABLED", "RECTIFICATION_AGENT_V5_SHADOW", "RECTIFICATION_AGENT_V5_CANARY_PERCENT"] as const;
const before = Object.fromEntries(keys.map((key) => [key, process.env[key]]));