Carry explicit local date intervals instead of inferring the day from clock order. Cluster width, delivery, adoption, and reports keep the actual civil date; adopted date is stored separately from the reported birth_date. Algorithm identity is scoring-9 / spec-v5. Scoring weights, confirmation thresholds, and Skill version are unchanged. Isolated Linux final-3 gates passed; four pre-existing Python failures remain. This is not a production release.
233 lines
19 KiB
TypeScript
233 lines
19 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { randomUUID } from "node:crypto";
|
|
import { spawnSync } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
import test from "node:test";
|
|
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
|
|
import { engineRequestBody, RectificationEngineError } from "../src/lib/rectification-agentic/v9/engine-client.ts";
|
|
import { scoreAndPersistCurrentEvidence } from "../src/lib/rectification-agentic/v9/score-persist.ts";
|
|
|
|
const docker = spawnSync("docker", ["version"], { stdio: "ignore" }).status === 0;
|
|
test("dated window RPC preserves original midnight anchor, D1 choices and ownership", { skip: !docker && "docker unavailable" }, () => {
|
|
const fixture = startPostgresFixture();
|
|
try {
|
|
const migration = spawnSync(process.execPath, [fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url))], {
|
|
encoding: "utf8", env: { ...process.env, SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password") },
|
|
});
|
|
assert.equal(migration.status, 0, migration.stderr);
|
|
const user = randomUUID();
|
|
fixture.psqlAs("identity_runtime", "identity-runtime-test-password", `insert into identity.users(id,name,email,email_verified) values('${user}','Fictional window','${user}@example.invalid',true)`);
|
|
const makeCase = (snapshot: object, range: object) => {
|
|
const id = randomUUID(), session = randomUUID();
|
|
fixture.psql(`insert into public.chat_sessions(id,user_id,title,theme,session_type,messages) values('${session}','${user}','Fictional window','general','birth_time_rectification','[]')`);
|
|
fixture.psql(`insert into public.agentic_rectification_cases(id,user_id,session_id,status,stage,skill_name,skill_version,baseline_profile_fingerprint,baseline_birth_snapshot,candidate_range)
|
|
values('${id}','${user}','${session}','candidate_ready','block_scan','jyotish-birth-time-rectification','9.0.0','${"a".repeat(64)}','${JSON.stringify(snapshot)}','${JSON.stringify(range)}')`);
|
|
return id;
|
|
};
|
|
const call = (sql: string) => JSON.parse(fixture.psql(`select ${sql}`));
|
|
const narrow = makeCase({ birth_date: "2000-03-01", reported_birth_time: "00:10", birth_time_source: "approximate", uncertainty_before_minutes: 15, uncertainty_after_minutes: 15 }, { start_time: "23:55", end_time: "23:59" });
|
|
const anchored = call(`public.ensure_agentic_rectification_dated_window('${user}','${narrow}')`);
|
|
assert.deepEqual(anchored.candidate_intervals, [{ start_at: "2000-02-29T23:55", end_at: "2000-02-29T23:59" }]);
|
|
for (const side of ["A", "B", "C", "skip"] as const) {
|
|
const id = makeCase({ birth_date: "2000-03-01", birth_time_source: "period_only", birth_time_period: "late_night" }, { start_time: "23:00", end_time: "03:59" });
|
|
const original = call(`public.ensure_agentic_rectification_dated_window('${user}','${id}')`);
|
|
assert.equal(original.midnight_side_pending, true);
|
|
const parts = side === "A" ? [original.candidate_intervals[1]] : side === "B" ? [original.candidate_intervals[0]] : original.candidate_intervals;
|
|
const start = side === "A" ? "23:00" : side === "B" ? "00:00" : "23:00";
|
|
const end = side === "A" ? "23:59" : "03:59";
|
|
const changed = call(`public.advance_agentic_rectification_dated_window('${user}','${id}','${start}','${end}','${JSON.stringify(parts)}',false)`);
|
|
assert.deepEqual(changed.candidate_range.candidate_intervals, parts);
|
|
const reloaded = call(`public.ensure_agentic_rectification_dated_window('${user}','${id}')`);
|
|
assert.equal(reloaded.midnight_side_pending, false);
|
|
assert.deepEqual(reloaded.candidate_intervals, parts);
|
|
}
|
|
assert.throws(() => call(`public.ensure_agentic_rectification_dated_window('${randomUUID()}','${narrow}')`), /case_not_found/);
|
|
assert.throws(() => call(`public.advance_agentic_rectification_dated_window('${user}','${narrow}','23:55','23:59','[{"start_at":"2000-03-01T23:55","end_at":"2000-03-01T23:59"}]',false)`), /invalid_block_window/);
|
|
} finally { fixture.stop(); }
|
|
});
|
|
|
|
test("dated widen preserves persisted dates and late-night allowed sets transactionally", { skip: !docker && "docker unavailable" }, async (t) => {
|
|
const fixture = startPostgresFixture();
|
|
try {
|
|
const migration = spawnSync(process.execPath, [fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url))], {
|
|
encoding: "utf8", env: { ...process.env, SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password") },
|
|
});
|
|
assert.equal(migration.status, 0, migration.stderr);
|
|
const user = randomUUID();
|
|
fixture.psqlAs("identity_runtime", "identity-runtime-test-password", `insert into identity.users(id,name,email,email_verified) values('${user}','Fictional widen','${user}@example.invalid',true)`);
|
|
const snapshot = { birth_date: "2000-03-01", latitude: 0, longitude: 0, timezone_offset: 0, reported_birth_time: "00:10", birth_time_source: "approximate" };
|
|
const nightSnapshot = { ...snapshot, reported_birth_time: null, birth_time_source: "period_only", birth_time_period: "late_night" };
|
|
const both = [
|
|
{ start_at: "2000-03-01T00:00", end_at: "2000-03-01T03:59" },
|
|
{ start_at: "2000-03-01T23:00", end_at: "2000-03-01T23:59" },
|
|
];
|
|
const makeCase = (range: object, birth: object = snapshot) => {
|
|
const id = randomUUID(), session = randomUUID();
|
|
fixture.psql(`insert into public.chat_sessions(id,user_id,title,theme,session_type,messages) values('${session}','${user}','Fictional widen','general','birth_time_rectification','[]');
|
|
insert into public.agentic_rectification_cases(id,user_id,session_id,status,stage,skill_name,skill_version,baseline_profile_fingerprint,baseline_birth_snapshot,candidate_range)
|
|
values('${id}','${user}','${session}','candidate_ready','minute','jyotish-birth-time-rectification','9.0.0','${"a".repeat(64)}','${JSON.stringify(birth)}','${JSON.stringify(range)}');
|
|
insert into public.agentic_rectification_results(user_id,session_id,case_id,engine_result_id,canonical_input_hash,algorithm_version,candidate_range,candidates,overall_confidence,baseline_birth_date,baseline_latitude,baseline_longitude,baseline_timezone_offset)
|
|
values('${user}','${session}','${id}','fictional-${id}','${"b".repeat(64)}','rectification-v5-matrix-scoring-8','${JSON.stringify(range)}','[]','low','2000-03-01',0,0,0)`);
|
|
return id;
|
|
};
|
|
const call = (sql: string) => JSON.parse(fixture.psql(`select ${sql}`));
|
|
const widen = (id: string, start: string, end: string, caller = user) => call(`public.widen_agentic_rectification_dated_window('${caller}','${id}','${start}','${end}')`);
|
|
const reload = (id: string) => call(`public.ensure_agentic_rectification_dated_window('${user}','${id}')`);
|
|
const state = (id: string) => fixture.psql(`select jsonb_build_object('case',(select to_jsonb(c) from public.agentic_rectification_cases c where id='${id}'),'results',(select jsonb_agg(to_jsonb(r) order by id) from public.agentic_rectification_results r where case_id='${id}'))`);
|
|
const rejectUnchanged = (id: string, start: string, end: string, error = /invalid_widen_window/, caller = user) => {
|
|
const before = state(id);
|
|
assert.throws(() => widen(id, start, end, caller), error);
|
|
assert.equal(state(id), before, "rejected transaction changes no case/result fields, including invalidation and timestamps");
|
|
};
|
|
const advance = (id: string, start: string, end: string, parts: object[]) => {
|
|
fixture.psql(`update public.agentic_rectification_cases set stage='block_scan' where id='${id}'`);
|
|
return call(`public.advance_agentic_rectification_dated_window('${user}','${id}','${start}','${end}','${JSON.stringify(parts)}',false)`);
|
|
};
|
|
const assertMinuteRequest = (id: string, expected: object[]) => {
|
|
const range = reload(id);
|
|
const birth = call(`(select baseline_birth_snapshot from public.agentic_rectification_cases where id='${id}')`);
|
|
const request = engineRequestBody({ baselineBirthSnapshot: birth, candidateRange: range, events: [{
|
|
id: "00000000-0000-4000-8000-000000000001", domain: "career", event_kind: "career_entry",
|
|
date_start: "2020-01-01", date_end: "2020-01-01", precision: "day", summary: "Fictional widen event",
|
|
}] });
|
|
assert.deepEqual(range.candidate_intervals, expected);
|
|
assert.deepEqual(request.candidate_intervals, expected, "DB reload to minute engine request keeps exact dates and holes");
|
|
};
|
|
await t.test("ordinary minute widening uses persisted previous-day anchor, not baseline re-guess", () => {
|
|
const id = makeCase({ start_time: "00:00", end_time: "00:20", candidate_intervals: [{ start_at: "2000-02-29T00:00", end_at: "2000-02-29T00:20" }] });
|
|
widen(id, "00:00", "00:30");
|
|
assertMinuteRequest(id, [{ start_at: "2000-02-29T00:00", end_at: "2000-02-29T00:30" }]);
|
|
});
|
|
await t.test("ordinary narrowed cross-midnight range never acquires a late-night side marker", () => {
|
|
const id = makeCase({ start_time: "23:55", end_time: "00:25", candidate_intervals: [{ start_at: "2000-02-29T23:55", end_at: "2000-03-01T00:25" }] });
|
|
advance(id, "00:00", "00:20", [{ start_at: "2000-03-01T00:00", end_at: "2000-03-01T00:20" }]);
|
|
assert.equal(Object.hasOwn(reload(id), "midnight_side_pending"), false);
|
|
widen(id, "23:30", "00:30");
|
|
assertMinuteRequest(id, [{ start_at: "2000-02-29T23:30", end_at: "2000-03-01T00:30" }]);
|
|
});
|
|
for (const side of ["A", "B", "C", "skip"] as const) {
|
|
await t.test(`late-night ${side} keeps original allowed set through narrow/widen/reload`, () => {
|
|
const id = makeCase({ start_time: "23:00", end_time: "03:59", candidate_intervals: both, midnight_side_pending: true }, nightSnapshot);
|
|
const chosen = side === "A" ? [both[1]] : side === "B" ? [both[0]] : both;
|
|
advance(id, side === "B" ? "00:00" : "23:00", side === "A" ? "23:59" : "03:59", chosen);
|
|
const narrowed = side === "A" ? [{ start_at: "2000-03-01T23:20", end_at: "2000-03-01T23:30" }] : [{ start_at: "2000-03-01T00:10", end_at: "2000-03-01T00:20" }];
|
|
advance(id, side === "A" ? "23:20" : "00:10", side === "A" ? "23:30" : "00:20", narrowed);
|
|
if (side === "A" || side === "B") {
|
|
rejectUnchanged(id, side === "A" ? "23:10" : "23:30", "00:30");
|
|
widen(id, side === "A" ? "23:00" : "00:00", side === "A" ? "23:40" : "00:30");
|
|
assertMinuteRequest(id, side === "A" ? [{ start_at: "2000-03-01T23:00", end_at: "2000-03-01T23:40" }] : [{ start_at: "2000-03-01T00:00", end_at: "2000-03-01T00:30" }]);
|
|
} else {
|
|
widen(id, "23:30", "00:30");
|
|
assertMinuteRequest(id, [
|
|
{ start_at: "2000-03-01T00:00", end_at: "2000-03-01T00:30" },
|
|
{ start_at: "2000-03-01T23:30", end_at: "2000-03-01T23:59" },
|
|
]);
|
|
}
|
|
assert.equal(reload(id).midnight_side_pending, false);
|
|
assert.deepEqual(reload(id).midnight_allowed_intervals, chosen, "a narrowed C/skip range is not a new user side choice");
|
|
});
|
|
}
|
|
await t.test("two surviving late-night segments widen without filling the daytime hole", () => {
|
|
const id = makeCase({ start_time: "23:50", end_time: "00:10", candidate_intervals: [
|
|
{ start_at: "2000-03-01T00:00", end_at: "2000-03-01T00:10" },
|
|
{ start_at: "2000-03-01T23:50", end_at: "2000-03-01T23:59" },
|
|
], midnight_side_pending: false, midnight_allowed_intervals: both }, nightSnapshot);
|
|
widen(id, "23:40", "00:20");
|
|
assertMinuteRequest(id, [{ start_at: "2000-03-01T00:00", end_at: "2000-03-01T00:20" }, { start_at: "2000-03-01T23:40", end_at: "2000-03-01T23:59" }]);
|
|
});
|
|
await t.test("real DB dossier/compute reload reaches minute score fetch with widened dated union", async (serviceTest) => {
|
|
const id = makeCase({ start_time: "23:00", end_time: "03:59", candidate_intervals: both, midnight_side_pending: true }, nightSnapshot);
|
|
advance(id, "23:00", "03:59", both); // D1 C/skip, not a side selection.
|
|
advance(id, "00:10", "00:20", [{ start_at: "2000-03-01T00:10", end_at: "2000-03-01T00:20" }]);
|
|
widen(id, "23:30", "00:30");
|
|
assert.equal(fixture.psql(`select stage from public.agentic_rectification_cases where id='${id}'`), "minute");
|
|
const turn = randomUUID();
|
|
fixture.psql(`insert into public.agentic_rectification_turns(id,case_id,status,model_name,user_message) values('${turn}','${id}','pending','fictional-test','Fictional career event');
|
|
insert into public.agentic_rectification_evidence(case_id,source_turn_id,user_quote,subject,event_kind,domain,occurred_from,occurred_to,date_precision,summary,status,confirmed_at)
|
|
values('${id}','${turn}','Fictional career event','self','career_entry','career','2020-01-01','2020-01-01','day','Fictional career event','confirmed',now())`);
|
|
const calls: string[] = [];
|
|
const accounting = { rpc: async (fn: string, args: Record<string, unknown>) => {
|
|
calls.push(fn);
|
|
assert.ok(["get_agentic_rectification_case_dossier", "get_agentic_rectification_case_compute"].includes(fn));
|
|
assert.equal(args.p_user_id, user);
|
|
assert.equal(args.p_case_id, id);
|
|
return { data: call(`public.${fn}('${user}','${id}')`), error: null };
|
|
} };
|
|
const keys = ["RECTIFICATION_ENGINE_VERSION", "RECTIFICATION_ALGORITHM_VERSION", "RECTIFICATION_DECISION_POLICY_VERSION"];
|
|
const saved = keys.map(key => [key, process.env[key]] as const);
|
|
for (const key of keys) delete process.env[key];
|
|
serviceTest.after(() => { for (const [key, value] of saved) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } });
|
|
let scoreBody: Record<string, unknown> | null = null;
|
|
serviceTest.mock.method(globalThis, "fetch", async (url: unknown, init?: RequestInit) => {
|
|
if (String(url).endsWith("/versions")) return Response.json({ algorithm_version: "rectification-v5-matrix-scoring-9", decision_policy_version: "rectification-candidate-policy-v3" });
|
|
assert.ok(String(url).endsWith("/score"), "minute service must call score, not block_scan");
|
|
scoreBody = JSON.parse(String(init?.body));
|
|
// End at the network boundary: no fabricated engine contract is needed
|
|
// to prove the real DB -> parser -> service -> outgoing request chain.
|
|
return Response.json({ error: "fictional_network_stop_after_capture" }, { status: 400 });
|
|
});
|
|
await assert.rejects(scoreAndPersistCurrentEvidence({ accounting: accounting as never, userId: user, caseId: id }),
|
|
(error: unknown) => error instanceof RectificationEngineError
|
|
&& error.httpStatus === 400 && error.message === "fictional_network_stop_after_capture");
|
|
assert.deepEqual(calls, ["get_agentic_rectification_case_dossier", "get_agentic_rectification_case_compute"]);
|
|
assert.ok(scoreBody, "actual score fetch must be reached");
|
|
assert.equal((scoreBody as Record<string, unknown>).birth_date, "2000-03-01");
|
|
assert.equal((scoreBody as Record<string, unknown>).tz, 0);
|
|
assert.equal((scoreBody as Record<string, unknown>).start_time, "23:30");
|
|
assert.equal((scoreBody as Record<string, unknown>).end_time, "00:30");
|
|
assert.deepEqual((scoreBody as Record<string, unknown>).candidate_intervals, [
|
|
{ start_at: "2000-03-01T00:00", end_at: "2000-03-01T00:30" },
|
|
{ start_at: "2000-03-01T23:30", end_at: "2000-03-01T23:59" },
|
|
]);
|
|
});
|
|
await t.test("legacy pending=false without allowed provenance never guesses a discarded side", () => {
|
|
const id = makeCase({ start_time: "00:00", end_time: "00:30", candidate_intervals: [
|
|
{ start_at: "2000-03-01T00:00", end_at: "2000-03-01T00:30" },
|
|
], midnight_side_pending: false }, nightSnapshot);
|
|
rejectUnchanged(id, "23:30", "00:30");
|
|
assertMinuteRequest(id, [{ start_at: "2000-03-01T00:00", end_at: "2000-03-01T00:30" }]);
|
|
const two = makeCase({ start_time: "23:50", end_time: "00:10", candidate_intervals: [
|
|
{ start_at: "2000-03-01T00:00", end_at: "2000-03-01T00:10" },
|
|
{ start_at: "2000-03-01T23:50", end_at: "2000-03-01T23:59" },
|
|
], midnight_side_pending: false }, nightSnapshot);
|
|
// Without recorded allowed provenance, retaining both existing segments
|
|
// and rejecting an unverifiable expansion is the safe legacy fallback.
|
|
rejectUnchanged(two, "23:40", "00:20");
|
|
assertMinuteRequest(two, [{ start_at: "2000-03-01T00:00", end_at: "2000-03-01T00:10" }, { start_at: "2000-03-01T23:50", end_at: "2000-03-01T23:59" }]);
|
|
});
|
|
await t.test("pending D1 cannot narrow with true or null; unchanged true remains compatible", () => {
|
|
for (const pending of ["true", "null"]) {
|
|
const id = makeCase({ start_time: "23:00", end_time: "03:59", candidate_intervals: both, midnight_side_pending: true }, nightSnapshot);
|
|
fixture.psql(`update public.agentic_rectification_cases set stage='block_scan' where id='${id}'`);
|
|
const before = state(id);
|
|
assert.throws(() => call(`public.advance_agentic_rectification_dated_window('${user}','${id}','00:00','03:59','${JSON.stringify([both[0]])}',${pending})`), /invalid_block_window/);
|
|
assert.equal(state(id), before, "invalid pending transition rolls back every case and result field");
|
|
}
|
|
const id = makeCase({ start_time: "23:00", end_time: "03:59", candidate_intervals: both, midnight_side_pending: true }, nightSnapshot);
|
|
fixture.psql(`update public.agentic_rectification_cases set stage='block_scan' where id='${id}'`);
|
|
const kept = call(`public.advance_agentic_rectification_dated_window('${user}','${id}','23:00','03:59','${JSON.stringify(both)}',true)`);
|
|
assert.equal(kept.candidate_range.midnight_side_pending, true);
|
|
assert.deepEqual(kept.candidate_range.candidate_intervals, both);
|
|
assert.deepEqual(kept.candidate_range.midnight_allowed_intervals, both);
|
|
});
|
|
await t.test("pending two segments preserve the gap and pending flag", () => {
|
|
const id = makeCase({ start_time: "23:50", end_time: "00:10", candidate_intervals: [
|
|
{ start_at: "2000-03-01T00:00", end_at: "2000-03-01T00:10" },
|
|
{ start_at: "2000-03-01T23:50", end_at: "2000-03-01T23:59" },
|
|
], midnight_side_pending: true, midnight_allowed_intervals: both }, nightSnapshot);
|
|
widen(id, "23:40", "00:20");
|
|
assertMinuteRequest(id, [{ start_at: "2000-03-01T00:00", end_at: "2000-03-01T00:20" }, { start_at: "2000-03-01T23:40", end_at: "2000-03-01T23:59" }]);
|
|
assert.equal(reload(id).midnight_side_pending, true);
|
|
});
|
|
await t.test("old containment, strictly-wider, max-width, ownership and terminal guards roll back", () => {
|
|
const id = makeCase({ start_time: "00:00", end_time: "00:20", candidate_intervals: [{ start_at: "2000-03-01T00:00", end_at: "2000-03-01T00:20" }] });
|
|
rejectUnchanged(id, "00:10", "00:30");
|
|
rejectUnchanged(id, "00:00", "00:20");
|
|
rejectUnchanged(id, "00:00", "05:00");
|
|
rejectUnchanged(id, "00:00", "00:30", /case_not_found/, randomUUID());
|
|
fixture.psql(`update public.agentic_rectification_cases set status='closed',completed_at=now() where id='${id}'`);
|
|
rejectUnchanged(id, "00:00", "00:30", /case_terminal/);
|
|
});
|
|
} finally { fixture.stop(); }
|
|
});
|