fix(rectification): anchor candidate windows to civil dates across midnight
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.
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
-- Explicit civil-date windows. Existing results and original profile dates are never rewritten.
|
||||
begin;
|
||||
do $$ begin
|
||||
if current_user <> 'schema_owner' then raise exception 'dated_windows_requires_schema_owner' using errcode='42501'; end if;
|
||||
end $$;
|
||||
|
||||
create or replace function public.rectification_dated_intervals(p_snapshot jsonb, p_range jsonb)
|
||||
returns jsonb language plpgsql immutable set search_path='' as $$
|
||||
declare
|
||||
d date := (p_snapshot->>'birth_date')::date;
|
||||
s text := p_range->>'start_time'; e text := p_range->>'end_time';
|
||||
reported text := left(coalesce(p_snapshot->>'reported_birth_time',''),5);
|
||||
a timestamp; b timestamp;
|
||||
begin
|
||||
if not public.agentic_rectification_is_clock(s) or not public.agentic_rectification_is_clock(e) then
|
||||
raise exception 'agentic_rectification_invalid_range';
|
||||
end if;
|
||||
if reported='' and p_snapshot->>'birth_time_source'='period_only'
|
||||
and p_snapshot->>'birth_time_period'='late_night'
|
||||
and coalesce(p_snapshot->>'declared_window_start','')='' and coalesce(p_snapshot->>'declared_window_end','')=''
|
||||
and s='23:00' and e='03:59' then
|
||||
return jsonb_build_array(jsonb_build_object('start_at',d::text||'T00:00','end_at',d::text||'T03:59'),
|
||||
jsonb_build_object('start_at',d::text||'T23:00','end_at',d::text||'T23:59'));
|
||||
end if;
|
||||
a := d+s::time; b := d+e::time;
|
||||
if reported<>'' then
|
||||
-- The declared minute is the anchor even after narrowing to one side of midnight.
|
||||
if s::time-reported::time>interval '12 hours' then a:=a-interval '1 day';
|
||||
elsif s::time-reported::time<interval '-12 hours' then a:=a+interval '1 day'; end if;
|
||||
b:=a+make_interval(mins=>public.agentic_rectification_clock_inclusive_width(s,e)-1);
|
||||
elsif coalesce(p_snapshot->>'declared_window_start','')>coalesce(p_snapshot->>'declared_window_end','')
|
||||
and s<p_snapshot->>'declared_window_start' then
|
||||
a:=a+interval '1 day'; b:=a+make_interval(mins=>public.agentic_rectification_clock_inclusive_width(s,e)-1);
|
||||
elsif e<s then b:=b+interval '1 day';
|
||||
end if;
|
||||
return jsonb_build_array(jsonb_build_object('start_at',to_char(a,'YYYY-MM-DD"T"HH24:MI'),'end_at',to_char(b,'YYYY-MM-DD"T"HH24:MI')));
|
||||
end $$;
|
||||
|
||||
create or replace function public.ensure_agentic_rectification_dated_window(p_user_id uuid,p_case_id uuid)
|
||||
returns jsonb language plpgsql security definer set search_path='' as $$
|
||||
declare c public.agentic_rectification_cases%rowtype; r jsonb; parts jsonb;
|
||||
reported text; anchor timestamp; lower_at timestamp; upper_at timestamp; before_minutes integer; after_minutes integer;
|
||||
begin
|
||||
select * into c from public.agentic_rectification_cases where id=p_case_id and user_id=p_user_id for update;
|
||||
if not found then raise exception 'agentic_rectification_case_not_found'; end if;
|
||||
if c.status in ('confirmed','closed','abandoned','superseded') then raise exception 'agentic_rectification_case_terminal'; end if;
|
||||
r:=c.candidate_range;
|
||||
if not (r ? 'candidate_intervals') then
|
||||
parts:=public.rectification_dated_intervals(c.baseline_birth_snapshot,r);
|
||||
reported:=left(coalesce(c.baseline_birth_snapshot->>'reported_birth_time',''),5);
|
||||
if reported<>'' then
|
||||
anchor:=(c.baseline_birth_snapshot->>'birth_date')::date+reported::time;
|
||||
before_minutes:=case when c.baseline_birth_snapshot->>'birth_time_source'='approximate'
|
||||
then greatest(15,least(120,coalesce((c.baseline_birth_snapshot->>'uncertainty_before_minutes')::integer,15))) else 15 end;
|
||||
after_minutes:=case when c.baseline_birth_snapshot->>'birth_time_source'='approximate'
|
||||
then greatest(15,least(120,coalesce((c.baseline_birth_snapshot->>'uncertainty_after_minutes')::integer,15))) else 15 end;
|
||||
lower_at:=greatest((parts->0->>'start_at')::timestamp,anchor-make_interval(mins=>before_minutes));
|
||||
upper_at:=least((parts->0->>'end_at')::timestamp,anchor+make_interval(mins=>after_minutes));
|
||||
if lower_at>upper_at then raise exception 'agentic_rectification_dated_window_requires_recompare'; end if;
|
||||
parts:=jsonb_build_array(jsonb_build_object('start_at',to_char(lower_at,'YYYY-MM-DD"T"HH24:MI'),
|
||||
'end_at',to_char(upper_at,'YYYY-MM-DD"T"HH24:MI')));
|
||||
end if;
|
||||
r:=r||jsonb_build_object('candidate_intervals',parts);
|
||||
if jsonb_array_length(parts)=2 then r:=r||jsonb_build_object('midnight_side_pending',true); end if;
|
||||
update public.agentic_rectification_cases set candidate_range=r where id=c.id;
|
||||
end if;
|
||||
return r;
|
||||
end $$;
|
||||
|
||||
create or replace function public.advance_agentic_rectification_dated_window(
|
||||
p_user_id uuid,p_case_id uuid,p_start_time text,p_end_time text,p_candidate_intervals jsonb,p_midnight_side_pending boolean default false
|
||||
) returns jsonb language plpgsql security definer set search_path='' as $$
|
||||
declare c public.agentic_rectification_cases%rowtype; parent jsonb; part jsonb; prev timestamp; a timestamp; b timestamp;
|
||||
total integer:=0; result jsonb; r jsonb;
|
||||
begin
|
||||
parent:=public.ensure_agentic_rectification_dated_window(p_user_id,p_case_id);
|
||||
select * into c from public.agentic_rectification_cases where id=p_case_id and user_id=p_user_id for update;
|
||||
if p_midnight_side_pending is null or (
|
||||
parent->'midnight_side_pending'='true'::jsonb and p_midnight_side_pending
|
||||
and p_candidate_intervals is distinct from parent->'candidate_intervals'
|
||||
) then raise exception 'agentic_rectification_invalid_block_window'; end if;
|
||||
if jsonb_typeof(p_candidate_intervals) is distinct from 'array' or jsonb_array_length(p_candidate_intervals) not between 1 and 2 then
|
||||
raise exception 'agentic_rectification_invalid_block_window'; end if;
|
||||
for part in select value from jsonb_array_elements(p_candidate_intervals) loop
|
||||
if coalesce(part->>'start_at','') !~ '^\d{4}-\d{2}-\d{2}T([01][0-9]|2[0-3]):[0-5][0-9]$'
|
||||
or coalesce(part->>'end_at','') !~ '^\d{4}-\d{2}-\d{2}T([01][0-9]|2[0-3]):[0-5][0-9]$' then
|
||||
raise exception 'agentic_rectification_invalid_block_window'; end if;
|
||||
a:=(part->>'start_at')::timestamp; b:=(part->>'end_at')::timestamp;
|
||||
if b<a or (prev is not null and a<=prev) or not exists (
|
||||
select 1 from jsonb_array_elements(parent->'candidate_intervals') x
|
||||
where a>=(x->>'start_at')::timestamp and b<=(x->>'end_at')::timestamp
|
||||
) then raise exception 'agentic_rectification_invalid_block_window'; end if;
|
||||
if exists (select 1 from generate_series(a,b,interval '1 minute') t where not public.agentic_rectification_clock_contains(
|
||||
p_start_time,p_end_time,to_char(t,'HH24:MI'),to_char(t,'HH24:MI'))) then
|
||||
raise exception 'agentic_rectification_invalid_block_window'; end if;
|
||||
total:=total+extract(epoch from b-a)::integer/60+1; prev:=b;
|
||||
end loop;
|
||||
if total>1440 then raise exception 'agentic_rectification_invalid_block_window'; end if;
|
||||
-- Delegate ownership, stage, round cap and result invalidation to the existing implementation.
|
||||
result:=public.advance_agentic_rectification_case_from_block_scan(p_user_id,p_case_id,p_start_time,p_end_time);
|
||||
r:=(result->'candidate_range')||jsonb_build_object('candidate_intervals',p_candidate_intervals);
|
||||
if parent ? 'midnight_side_pending' then
|
||||
-- Only an actual D1 choice may establish a side. Later narrowing must not
|
||||
-- reinterpret a surviving C/skip segment as a new user declaration.
|
||||
if parent->'midnight_side_pending'='true'::jsonb and not p_midnight_side_pending then
|
||||
if p_candidate_intervals<>parent->'candidate_intervals' and not exists (
|
||||
select 1 from jsonb_array_elements(parent->'candidate_intervals') x
|
||||
where p_candidate_intervals=jsonb_build_array(x)
|
||||
) then raise exception 'agentic_rectification_invalid_block_window'; end if;
|
||||
r:=r||jsonb_build_object('midnight_allowed_intervals',p_candidate_intervals);
|
||||
elsif parent ? 'midnight_allowed_intervals' then
|
||||
r:=r||jsonb_build_object('midnight_allowed_intervals',parent->'midnight_allowed_intervals');
|
||||
else
|
||||
-- Pre-provenance windows cannot regain an unknown discarded side.
|
||||
r:=r||jsonb_build_object('midnight_allowed_intervals',parent->'candidate_intervals');
|
||||
end if;
|
||||
r:=r||jsonb_build_object('midnight_side_pending',
|
||||
parent->'midnight_side_pending'='true'::jsonb and p_midnight_side_pending);
|
||||
end if;
|
||||
update public.agentic_rectification_cases set candidate_range=r where id=c.id;
|
||||
return result||jsonb_build_object('candidate_range',r);
|
||||
end $$;
|
||||
|
||||
create or replace function public.widen_agentic_rectification_dated_window(p_user_id uuid,p_case_id uuid,p_start_time text,p_end_time text)
|
||||
returns jsonb language plpgsql security definer set search_path='' as $$
|
||||
declare c public.agentic_rectification_cases%rowtype; result jsonb; r jsonb; parent jsonb;
|
||||
parts jsonb; allowed jsonb; a timestamp; b timestamp; width integer; total integer;
|
||||
begin
|
||||
select * into c from public.agentic_rectification_cases where id=p_case_id and user_id=p_user_id for update;
|
||||
if not found then raise exception 'agentic_rectification_case_not_found'; end if;
|
||||
parent:=public.ensure_agentic_rectification_dated_window(p_user_id,p_case_id);
|
||||
-- Keep every original ownership/stage/adoption/containment/strict-width guard.
|
||||
-- Any later exception rolls back this call's invalidation and all row writes.
|
||||
result:=public.widen_agentic_rectification_case_window(p_user_id,p_case_id,p_start_time,p_end_time);
|
||||
width:=public.agentic_rectification_clock_inclusive_width(p_start_time,p_end_time);
|
||||
r:=result->'candidate_range';
|
||||
if parent ? 'midnight_side_pending' then
|
||||
allowed:=coalesce(parent->'midnight_allowed_intervals',parent->'candidate_intervals');
|
||||
-- Intersect the requested clocks with the server-persisted allowed set.
|
||||
-- Group by actual consecutive civil minutes, never min/max across a hole.
|
||||
with minutes as (
|
||||
select distinct t from jsonb_array_elements(allowed) x
|
||||
cross join lateral generate_series((x->>'start_at')::timestamp,(x->>'end_at')::timestamp,interval '1 minute') t
|
||||
where public.agentic_rectification_clock_contains(p_start_time,p_end_time,to_char(t,'HH24:MI'),to_char(t,'HH24:MI'))
|
||||
), numbered as (
|
||||
select t,t-row_number() over(order by t)*interval '1 minute' as grp from minutes
|
||||
), segments as (
|
||||
select min(t) as a,max(t) as b from numbered group by grp
|
||||
) select jsonb_agg(jsonb_build_object('start_at',to_char(segments.a,'YYYY-MM-DD"T"HH24:MI'),
|
||||
'end_at',to_char(segments.b,'YYYY-MM-DD"T"HH24:MI')) order by segments.a) into parts from segments;
|
||||
r:=r||jsonb_build_object('midnight_side_pending',parent->'midnight_side_pending',
|
||||
'midnight_allowed_intervals',allowed);
|
||||
else
|
||||
if jsonb_array_length(parent->'candidate_intervals')<>1 then
|
||||
raise exception 'agentic_rectification_invalid_widen_window'; end if;
|
||||
a:=(parent->'candidate_intervals'->0->>'start_at')::timestamp;
|
||||
-- Align the new start before the persisted start, without consulting the
|
||||
-- declared date again (it may be the previous/next day's narrowed window).
|
||||
a:=a-make_interval(mins=>public.agentic_rectification_clock_inclusive_width(p_start_time,to_char(a,'HH24:MI'))-1);
|
||||
b:=a+make_interval(mins=>width-1);
|
||||
parts:=jsonb_build_array(jsonb_build_object('start_at',to_char(a,'YYYY-MM-DD"T"HH24:MI'),
|
||||
'end_at',to_char(b,'YYYY-MM-DD"T"HH24:MI')));
|
||||
end if;
|
||||
select sum(extract(epoch from (x->>'end_at')::timestamp-(x->>'start_at')::timestamp)::integer/60+1)
|
||||
into total from jsonb_array_elements(parts) x;
|
||||
if parts is null or total is distinct from width or exists (
|
||||
select 1 from jsonb_array_elements(parent->'candidate_intervals') old_part
|
||||
where not exists (select 1 from jsonb_array_elements(parts) new_part
|
||||
where (old_part->>'start_at')::timestamp>=(new_part->>'start_at')::timestamp
|
||||
and (old_part->>'end_at')::timestamp<=(new_part->>'end_at')::timestamp)
|
||||
) then raise exception 'agentic_rectification_invalid_widen_window'; end if;
|
||||
r:=r||jsonb_build_object('candidate_intervals',parts);
|
||||
update public.agentic_rectification_cases set candidate_range=r where id=c.id;
|
||||
return result||jsonb_build_object('candidate_range',r);
|
||||
end $$;
|
||||
|
||||
revoke all on function public.rectification_dated_intervals(jsonb,jsonb) from public,anon,authenticated;
|
||||
revoke all on function public.ensure_agentic_rectification_dated_window(uuid,uuid) from public,anon,authenticated;
|
||||
revoke all on function public.advance_agentic_rectification_dated_window(uuid,uuid,text,text,jsonb,boolean) from public,anon,authenticated;
|
||||
revoke all on function public.widen_agentic_rectification_dated_window(uuid,uuid,text,text) from public,anon,authenticated;
|
||||
grant execute on function public.rectification_dated_intervals(jsonb,jsonb) to service_role;
|
||||
grant execute on function public.ensure_agentic_rectification_dated_window(uuid,uuid) to service_role;
|
||||
grant execute on function public.advance_agentic_rectification_dated_window(uuid,uuid,text,text,jsonb,boolean) to service_role;
|
||||
grant execute on function public.widen_agentic_rectification_dated_window(uuid,uuid,text,text) to service_role;
|
||||
commit;
|
||||
Reference in New Issue
Block a user