fix(rectification): keep candidate state coherent
This commit is contained in:
@@ -22,8 +22,10 @@ const skipWithoutDocker = dockerAvailable() ? false : "docker unavailable on thi
|
||||
|
||||
const snapshot = {
|
||||
birth_date: "1997-08-08",
|
||||
birth_place_label: "河北省邯郸市武安市",
|
||||
latitude: 36.420487,
|
||||
longitude: 114.209936,
|
||||
timezone_id: "Asia/Shanghai",
|
||||
timezone_offset: 8,
|
||||
birth_time_source: "family_exact",
|
||||
reported_birth_time: "05:00",
|
||||
@@ -1085,7 +1087,7 @@ test("v9 agent api migration applies, seeds the runtime flag and guards consent"
|
||||
}
|
||||
});
|
||||
|
||||
test("v9 allows changing an accepted minute within the same live candidate result", { skip: skipWithoutDocker }, async () => {
|
||||
test("v9 ignores a historical active minute and allows reselection within the live result", { skip: skipWithoutDocker }, async () => {
|
||||
const fixture = startPostgresFixture();
|
||||
const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password");
|
||||
try {
|
||||
@@ -1107,9 +1109,9 @@ test("v9 allows changing an accepted minute within the same live candidate resul
|
||||
update public.profiles
|
||||
set birth_date = '1997-08-08',
|
||||
reported_birth_time = '05:00',
|
||||
active_birth_time = null,
|
||||
birth_time = '05:00',
|
||||
birth_time_status = 'reported',
|
||||
active_birth_time = '04:30',
|
||||
birth_time = '04:30',
|
||||
birth_time_status = 'accepted',
|
||||
birth_time_source = 'family_exact',
|
||||
birth_time_period = null,
|
||||
uncertainty_before_minutes = 10,
|
||||
@@ -1140,6 +1142,27 @@ test("v9 allows changing an accepted minute within the same live candidate resul
|
||||
null,
|
||||
"service_role",
|
||||
);
|
||||
const inferenceState = {
|
||||
algorithm_version: "rectification-inference-v1",
|
||||
candidate_set_id: "04:50-05:10:05:07,05:08,05:09",
|
||||
revision: 0,
|
||||
phase: "discrimination",
|
||||
result_status: "completed_with_range",
|
||||
range_start: "04:50",
|
||||
range_end: "05:10",
|
||||
candidates: [
|
||||
{ id: "05:07", time: "05:07", cluster_range: ["05:07", "05:07"], prior_score: 34, posterior_score: 34, probability: 0.34, status: "active", rank: 2, strong_conflict_count: 0 },
|
||||
{ id: "05:08", time: "05:08", cluster_range: ["05:08", "05:08"], prior_score: 33, posterior_score: 40, probability: 0.4, status: "active", rank: 1, strong_conflict_count: 0 },
|
||||
{ id: "05:09", time: "05:09", cluster_range: ["05:09", "05:09"], prior_score: 33, posterior_score: 26, probability: 0.26, status: "active", rank: 3, strong_conflict_count: 0 },
|
||||
],
|
||||
events: [],
|
||||
probes: [],
|
||||
answered_probes: [],
|
||||
rounds: [],
|
||||
entropy: 1,
|
||||
representative_time: "05:08",
|
||||
credible_range: ["05:07", "05:09"],
|
||||
};
|
||||
const persisted = await service.rpc("persist_agentic_rectification_candidate_v2", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
@@ -1163,6 +1186,7 @@ test("v9 allows changing an accepted minute within the same live candidate resul
|
||||
representative_time: "05:08",
|
||||
overall_confidence: "low",
|
||||
margin_percent: 1,
|
||||
inference_state: inferenceState,
|
||||
},
|
||||
p_execution_ledger: [
|
||||
{ phase: "candidate.score", status: "completed", engine: "fixture-engine" },
|
||||
@@ -1183,6 +1207,252 @@ test("v9 allows changing an accepted minute within the same live candidate resul
|
||||
assert.match(switchedCandidateId, /^[0-9a-f-]{36}$/);
|
||||
assert.match(thirdCandidateId, /^[0-9a-f-]{36}$/);
|
||||
|
||||
fixture.psql(`
|
||||
update public.agentic_rectification_results
|
||||
set decision_receipt = decision_receipt - 'inference_state'
|
||||
where id = '${resultId}';
|
||||
`);
|
||||
const missingInference = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_result_id: resultId,
|
||||
p_candidate_id: firstCandidateId,
|
||||
p_request_id: "01010101-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
});
|
||||
assert.match(rpcError(missingInference.error), /agentic_rectification_candidate_state_inconsistent/);
|
||||
|
||||
fixture.psql(`
|
||||
update public.agentic_rectification_results
|
||||
set decision_receipt = jsonb_set(
|
||||
decision_receipt,
|
||||
'{inference_state}',
|
||||
('${JSON.stringify(inferenceState)}'::jsonb - 'revision'),
|
||||
true
|
||||
)
|
||||
where id = '${resultId}';
|
||||
`);
|
||||
const missingFallbackRevision = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_result_id: resultId,
|
||||
p_candidate_id: firstCandidateId,
|
||||
p_request_id: "01010102-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
});
|
||||
assert.match(
|
||||
rpcError(missingFallbackRevision.error),
|
||||
/agentic_rectification_candidate_state_inconsistent/,
|
||||
"a fallback receipt without revision must fail closed",
|
||||
);
|
||||
|
||||
const eliminatedInference = {
|
||||
...inferenceState,
|
||||
candidates: inferenceState.candidates.map((candidate) => (
|
||||
candidate.time === "05:09"
|
||||
? { ...candidate, status: "eliminated", probability: 0 }
|
||||
: candidate
|
||||
)),
|
||||
credible_range: ["05:07", "05:08"],
|
||||
};
|
||||
fixture.psql(`
|
||||
update public.agentic_rectification_results
|
||||
set decision_receipt = jsonb_set(decision_receipt, '{inference_state}', '${JSON.stringify(eliminatedInference)}'::jsonb, true)
|
||||
where id = '${resultId}';
|
||||
`);
|
||||
const eliminatedCandidate = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_result_id: resultId,
|
||||
p_candidate_id: thirdCandidateId,
|
||||
p_request_id: "02020202-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
});
|
||||
assert.match(rpcError(eliminatedCandidate.error), /agentic_rectification_candidate_state_inconsistent/);
|
||||
fixture.psql(`
|
||||
update public.agentic_rectification_results
|
||||
set decision_receipt = jsonb_set(decision_receipt, '{inference_state}', '${JSON.stringify(inferenceState)}'::jsonb, true)
|
||||
where id = '${resultId}';
|
||||
`);
|
||||
|
||||
const latestInferenceState = { ...inferenceState, revision: 1 };
|
||||
fixture.psql(`
|
||||
insert into public.agentic_rectification_inference_transitions (
|
||||
case_id, result_id, previous_revision, revision, candidate_set_id,
|
||||
probe_id, semantic_key, candidate_split_hash, answer_class, raw_answer,
|
||||
posterior_before, posterior_after, score_deltas, inference_state,
|
||||
decision_state_fingerprint, reason, idempotency_key, receipt_after
|
||||
) values (
|
||||
'${caseId}', '${resultId}', 0, 1, '${inferenceState.candidate_set_id}',
|
||||
'probe:fixture', 'fixture.latest-transition', '${"d".repeat(64)}', 'no', 'fixture',
|
||||
'{}'::jsonb, '{}'::jsonb, '{}'::jsonb, '${JSON.stringify(latestInferenceState)}'::jsonb,
|
||||
'${"e".repeat(64)}', 'choice', 'fixture-transition-revision-1', '{}'::jsonb
|
||||
);
|
||||
`);
|
||||
assert.equal(
|
||||
fixture.psql(`select decision_receipt #>> '{inference_state,revision}' from public.agentic_rectification_results where id = '${resultId}'`),
|
||||
"0",
|
||||
"the immutable engine receipt must remain at revision 0",
|
||||
);
|
||||
const composedRevision = () => fixture.psql(`
|
||||
select public.compose_agentic_rectification_decision_receipt(
|
||||
'${caseId}',
|
||||
'${resultId}',
|
||||
decision_receipt
|
||||
) #>> '{inference_state,revision}'
|
||||
from public.agentic_rectification_results
|
||||
where id = '${resultId}'
|
||||
`);
|
||||
const composedHasInference = () => fixture.psql(`
|
||||
select public.compose_agentic_rectification_decision_receipt(
|
||||
'${caseId}',
|
||||
'${resultId}',
|
||||
decision_receipt
|
||||
) ? 'inference_state'
|
||||
from public.agentic_rectification_results
|
||||
where id = '${resultId}'
|
||||
`);
|
||||
assert.equal(composedRevision(), "1", "a coherent latest transition must overlay the read receipt");
|
||||
|
||||
fixture.psql(`
|
||||
update public.agentic_rectification_inference_transitions
|
||||
set inference_state = jsonb_set(inference_state, '{revision}', '2'::jsonb, true)
|
||||
where case_id = '${caseId}' and revision = 1;
|
||||
`);
|
||||
assert.equal(composedHasInference(), "f", "a nested revision mismatch must hide stale read candidates");
|
||||
|
||||
fixture.psql(`
|
||||
update public.agentic_rectification_inference_transitions
|
||||
set inference_state = jsonb_set(
|
||||
'${JSON.stringify(latestInferenceState)}'::jsonb,
|
||||
'{candidate_set_id}',
|
||||
to_jsonb('wrong-set'::text),
|
||||
true
|
||||
)
|
||||
where case_id = '${caseId}' and revision = 1;
|
||||
`);
|
||||
assert.equal(composedHasInference(), "f", "a nested candidate-set mismatch must hide stale read candidates");
|
||||
|
||||
fixture.psql(`
|
||||
update public.agentic_rectification_inference_transitions
|
||||
set inference_state = '${JSON.stringify(latestInferenceState)}'::jsonb
|
||||
where case_id = '${caseId}' and revision = 1;
|
||||
`);
|
||||
assert.equal(composedRevision(), "1", "restoring a coherent transition must restore the overlay");
|
||||
|
||||
const malformedLatestInferenceStates = [
|
||||
{
|
||||
label: "status",
|
||||
state: {
|
||||
...latestInferenceState,
|
||||
candidates: latestInferenceState.candidates.map((candidate, index) => (
|
||||
index === 0 ? Object.fromEntries(Object.entries(candidate).filter(([key]) => key !== "status")) : candidate
|
||||
)),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "probability",
|
||||
state: {
|
||||
...latestInferenceState,
|
||||
candidates: latestInferenceState.candidates.map((candidate, index) => (
|
||||
index === 0 ? Object.fromEntries(Object.entries(candidate).filter(([key]) => key !== "probability")) : candidate
|
||||
)),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "posterior_score",
|
||||
state: {
|
||||
...latestInferenceState,
|
||||
candidates: latestInferenceState.candidates.map((candidate, index) => (
|
||||
index === 0 ? Object.fromEntries(Object.entries(candidate).filter(([key]) => key !== "posterior_score")) : candidate
|
||||
)),
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const [index, malformed] of malformedLatestInferenceStates.entries()) {
|
||||
fixture.psql(`
|
||||
update public.agentic_rectification_inference_transitions
|
||||
set inference_state = '${JSON.stringify(malformed.state)}'::jsonb
|
||||
where case_id = '${caseId}' and revision = 1;
|
||||
`);
|
||||
const malformedAccept = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_result_id: resultId,
|
||||
p_candidate_id: firstCandidateId,
|
||||
p_request_id: `0303030${index + 3}-aaaa-4aaa-8aaa-aaaaaaaaaaaa`,
|
||||
});
|
||||
assert.match(
|
||||
rpcError(malformedAccept.error),
|
||||
/agentic_rectification_candidate_state_inconsistent/,
|
||||
`missing ${malformed.label} must fail closed`,
|
||||
);
|
||||
}
|
||||
fixture.psql(`
|
||||
update public.agentic_rectification_inference_transitions
|
||||
set inference_state = '${JSON.stringify(latestInferenceState)}'::jsonb
|
||||
where case_id = '${caseId}' and revision = 1;
|
||||
`);
|
||||
|
||||
fixture.psql(`
|
||||
update public.profiles
|
||||
set declared_window_start = '04:45', declared_window_end = '05:15'
|
||||
where id = '${userId}';
|
||||
`);
|
||||
assert.equal(
|
||||
fixture.psql(`select status from public.agentic_rectification_cases where id = '${caseId}'`),
|
||||
"needs_rebaseline",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select invalidated_at is not null from public.agentic_rectification_results where id = '${resultId}'`),
|
||||
"t",
|
||||
);
|
||||
fixture.psql(`
|
||||
set session_replication_role = replica;
|
||||
update public.profiles
|
||||
set declared_window_start = null, declared_window_end = null
|
||||
where id = '${userId}';
|
||||
update public.agentic_rectification_cases
|
||||
set status = 'candidate_ready'
|
||||
where id = '${caseId}';
|
||||
update public.agentic_rectification_results
|
||||
set invalidated_at = null
|
||||
where id = '${resultId}';
|
||||
set session_replication_role = origin;
|
||||
|
||||
update public.profiles
|
||||
set birth_place_label = '邯郸(用户备注)', active_birth_time = '04:31'
|
||||
where id = '${userId}';
|
||||
`);
|
||||
assert.equal(
|
||||
fixture.psql(`select status from public.agentic_rectification_cases where id = '${caseId}'`),
|
||||
"candidate_ready",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select invalidated_at is null from public.agentic_rectification_results where id = '${resultId}'`),
|
||||
"t",
|
||||
);
|
||||
|
||||
fixture.psql(`
|
||||
set session_replication_role = replica;
|
||||
update public.profiles set timezone_id = 'Asia/Taipei' where id = '${userId}';
|
||||
set session_replication_role = origin;
|
||||
`);
|
||||
const staleTimezoneFirstAccept = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_result_id: resultId,
|
||||
p_candidate_id: firstCandidateId,
|
||||
p_request_id: "00000000-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
});
|
||||
assert.match(rpcError(staleTimezoneFirstAccept.error), /agentic_rectification_candidate_profile_changed/);
|
||||
assert.equal(
|
||||
fixture.psql(`select selected_candidate_id is null from public.agentic_rectification_results where id = '${resultId}'`),
|
||||
"t",
|
||||
);
|
||||
fixture.psql(`
|
||||
set session_replication_role = replica;
|
||||
update public.profiles set timezone_id = 'Asia/Shanghai' where id = '${userId}';
|
||||
set session_replication_role = origin;
|
||||
`);
|
||||
|
||||
const firstAccept = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
@@ -1192,6 +1462,41 @@ test("v9 allows changing an accepted minute within the same live candidate resul
|
||||
});
|
||||
assert.equal(firstAccept.error, null, rpcError(firstAccept.error));
|
||||
|
||||
fixture.psql(`
|
||||
set session_replication_role = replica;
|
||||
update public.profiles
|
||||
set birth_date = '1997-08-09',
|
||||
birth_place_label = '台北市',
|
||||
latitude = 25.033,
|
||||
longitude = 121.5654,
|
||||
timezone_id = 'Asia/Taipei'
|
||||
where id = '${userId}';
|
||||
set session_replication_role = origin;
|
||||
`);
|
||||
const staleReselection = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_result_id: resultId,
|
||||
p_candidate_id: switchedCandidateId,
|
||||
p_request_id: "12121212-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
});
|
||||
assert.match(rpcError(staleReselection.error), /agentic_rectification_candidate_profile_changed/);
|
||||
assert.equal(
|
||||
fixture.psql(`select selected_candidate_id from public.agentic_rectification_results where id = '${resultId}'`),
|
||||
firstCandidateId,
|
||||
);
|
||||
fixture.psql(`
|
||||
set session_replication_role = replica;
|
||||
update public.profiles
|
||||
set birth_date = '1997-08-08',
|
||||
birth_place_label = '河北省邯郸市武安市',
|
||||
latitude = 36.420487,
|
||||
longitude = 114.209936,
|
||||
timezone_id = 'Asia/Shanghai'
|
||||
where id = '${userId}';
|
||||
set session_replication_role = origin;
|
||||
`);
|
||||
|
||||
const switchRequestId = "22222222-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const switched = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", {
|
||||
p_user_id: userId,
|
||||
|
||||
Reference in New Issue
Block a user