feat: add VedAstro rectification post-validation
This commit is contained in:
@@ -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]]));
|
||||
|
||||
Reference in New Issue
Block a user