diff --git a/pkg/litigationplanner/before_court_set_anchor_test.go b/pkg/litigationplanner/before_court_set_anchor_test.go index 1c380a3..492bfea 100644 --- a/pkg/litigationplanner/before_court_set_anchor_test.go +++ b/pkg/litigationplanner/before_court_set_anchor_test.go @@ -205,7 +205,11 @@ func TestCalculate_BeforeChildOfCourtSetParent_OutOfOrderSequence(t *testing.T) cat := &stubCatalog{pt: pt, rules: rules} - timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{}) + // IncludeOptional=true because translation_request carries + // priority='optional'; the test exercises the before-child-of- + // court-set-parent flow, which is orthogonal to the optional-rule + // suppression added in t-paliad-342. + timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{IncludeOptional: true}, cat, noOpHolidays{}, fixedCourts{}) if err != nil { t.Fatalf("Calculate: %v", err) } @@ -301,8 +305,10 @@ func TestCalculate_BeforeChildOfCourtSetParent_WithOverride(t *testing.T) { cat := &stubCatalog{pt: pt, rules: rules} - // User pins the oral hearing to 2026-10-15. + // User pins the oral hearing to 2026-10-15. IncludeOptional=true + // because translation_request is priority='optional' (t-paliad-342). opts := CalcOptions{ + IncludeOptional: true, AnchorOverrides: map[string]string{ oralCode: "2026-10-15", }, diff --git a/pkg/litigationplanner/engine.go b/pkg/litigationplanner/engine.go index 486fb33..42e8cc5 100644 --- a/pkg/litigationplanner/engine.go +++ b/pkg/litigationplanner/engine.go @@ -80,6 +80,21 @@ func Calculate( overrideDates[code] = od } + // Trigger-event anchors keyed by paliad.trigger_events.code + // (t-paliad-342). Parsed up-front so malformed dates error before + // the rule walk. When a rule has trigger_event_id set, the engine + // looks up triggerAnchorByCode[trigger_event.code] for the + // semantic anchor instead of falling back to the proceeding's + // trigger date. + triggerAnchorByCode := make(map[string]time.Time, len(opts.TriggerEventAnchors)) + for code, dateStr := range opts.TriggerEventAnchors { + td, err := time.Parse("2006-01-02", dateStr) + if err != nil { + return nil, fmt.Errorf("invalid trigger event anchor for %q (%q): %w", code, dateStr, err) + } + triggerAnchorByCode[code] = td + } + // Look up proceeding type metadata. pickedProceeding, rules, err := catalog.LoadProceeding(ctx, proceedingCode, opts.ProjectHint) if err != nil { @@ -213,6 +228,7 @@ func Calculate( perCardAppellant := opts.PerCardAppellant skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules)) hiddenCount := 0 + rulesAwaitingAnchor := 0 appellantContext := make(map[uuid.UUID]string, len(rules)) for _, r := range walkRules { @@ -227,6 +243,17 @@ func Calculate( continue } + // Optional-rule suppression (t-paliad-342 / youpcorg#2570). + // Rules tagged priority='optional' don't auto-fire in the + // default timeline; the caller opts in via + // CalcOptions.IncludeOptional. Cascade through skippedIDs so + // children chaining off the suppressed rule also drop — they + // can't compute a date against a missing parent. + if r.Priority == "optional" && !opts.IncludeOptional { + skippedIDs[r.ID] = struct{}{} + continue + } + // SkipRules suppression (t-paliad-265). // t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set, // we re-surface the directly-skipped row (faded via IsHidden) @@ -327,15 +354,43 @@ func Calculate( // (m/paliad#126 / t-paliad-294). When a rule has a real // trigger_event_id, that catalog event is the actual semantic // anchor — not the parent_id node, which is only the calc-time - // arithmetic anchor. Only the user-facing wire fields shift; - // parentRule (and the parent_id chain feeding parentIsCourtSet - // and the calc-time arithmetic below) stays anchored on the - // rule tree. + // arithmetic anchor. Only the user-facing wire fields shift + // here; the calc-time anchor logic for trigger_event_id rules + // lives just below. + var triggerEventAnchor time.Time + var hasTriggerEventAnchor bool if r.TriggerEventID != nil { if te, ok := triggerEventByID[*r.TriggerEventID]; ok { d.ParentRuleCode = te.Code d.ParentRuleName = te.NameDE d.ParentRuleNameEN = te.Name + if td, ok := triggerAnchorByCode[te.Code]; ok { + triggerEventAnchor = td + hasTriggerEventAnchor = true + } + } + + // Trigger-event semantic-anchor suppression (t-paliad-342 / + // youpcorg#2568). When a rule has an explicit trigger_event_id + // but the caller hasn't supplied a date for that event via + // CalcOptions.TriggerEventAnchors, the engine refuses to + // fabricate a date off the proceeding's trigger date — the + // rule's semantic anchor is the event itself, not the SoC. + // Render IsConditional with empty dates and propagate via + // courtSet so descendants chaining off this rule also surface + // as conditional rather than projecting fictional dates. + if !hasTriggerEventAnchor { + d.IsConditional = true + d.IsCourtSet = true + d.DueDate = "" + d.OriginalDate = "" + courtSet[r.ID] = true + rulesAwaitingAnchor++ + if r.SubmissionCode != nil { + skippedIDs[r.ID] = struct{}{} + } + deadlines = append(deadlines, d) + continue } } @@ -379,6 +434,20 @@ func Calculate( } } + // Trigger-event anchor wins over the bucket logic below: a + // zero-duration rule with trigger_event_id is "occurs on the + // trigger event's date". Anchor missing was already caught + // above (suppression branch). + if hasTriggerEventAnchor { + d.DueDate = triggerEventAnchor.Format("2006-01-02") + d.OriginalDate = d.DueDate + if r.SubmissionCode != nil { + computed[*r.SubmissionCode] = triggerEventAnchor + } + deadlines = append(deadlines, d) + continue + } + if r.ParentID == nil && !r.IsCourtSet { // Bucket 1: timeline anchor. d.IsRootEvent = true @@ -457,11 +526,19 @@ func Calculate( continue } - // Anchor: prefer alt-anchor (e.g. priority_date for - // epa.grant.exa publish) when supplied, then parent's computed - // date (or user override), then trigger date. + // Anchor priority: + // 1. trigger_event_id semantic anchor (t-paliad-342) — when + // the rule has trigger_event_id and the caller supplied a + // date in TriggerEventAnchors, that date wins over the + // parent chain AND the priority_date alt-anchor. The + // missing-anchor case was already short-circuited above. + // 2. priority_date alt-anchor (epa.grant.exa publish). + // 3. parent's computed date (or user override). + // 4. proceeding trigger date (default fallback). baseDate := triggerDate - if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil { + if hasTriggerEventAnchor { + baseDate = triggerEventAnchor + } else if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil { baseDate = *priorityDate } else if r.ParentID != nil { for _, prev := range rules { @@ -635,12 +712,13 @@ func Calculate( } resp := &Timeline{ - ProceedingType: pickedProceeding.Code, - ProceedingName: pickedProceeding.Name, - ProceedingNameEN: pickedProceeding.NameEN, - TriggerDate: triggerDateStr, - Deadlines: deadlines, - HiddenCount: hiddenCount, + ProceedingType: pickedProceeding.Code, + ProceedingName: pickedProceeding.Name, + ProceedingNameEN: pickedProceeding.NameEN, + TriggerDate: triggerDateStr, + Deadlines: deadlines, + HiddenCount: hiddenCount, + RulesAwaitingAnchor: rulesAwaitingAnchor, } // Sub-track routing keeps the user-picked proceeding's identity, // so the trigger-event label rides on `pickedProceeding`. diff --git a/pkg/litigationplanner/optional_and_trigger_anchor_test.go b/pkg/litigationplanner/optional_and_trigger_anchor_test.go new file mode 100644 index 0000000..49b7126 --- /dev/null +++ b/pkg/litigationplanner/optional_and_trigger_anchor_test.go @@ -0,0 +1,379 @@ +package litigationplanner + +import ( + "context" + "testing" + + "github.com/google/uuid" +) + +// Tests for t-paliad-342 / youpcorg#2568 + #2570. +// +// Two paired engine semantics: +// +// - Optional rules (priority='optional') don't auto-fire in the +// default timeline; the caller opts in via +// CalcOptions.IncludeOptional. +// - Rules with explicit trigger_event_id anchor on the trigger +// event's date (CalcOptions.TriggerEventAnchors keyed by +// trigger_events.code). Missing anchor = render conditional +// instead of fabricating a date off the proceeding's trigger date. + +// stubCatalogWithTriggers extends stubCatalog with a trigger-events +// map so the engine can resolve TriggerEventID → code for the +// trigger-anchor branch. stubCatalog from before_court_set_anchor_test.go +// returns an empty map, which suffices for tests that don't exercise +// trigger_event_id; here we need real entries. +type stubCatalogWithTriggers struct { + stubCatalog + triggerEvents map[int64]TriggerEvent +} + +func (s *stubCatalogWithTriggers) LoadTriggerEventsByIDs(_ context.Context, ids []int64) (map[int64]TriggerEvent, error) { + out := make(map[int64]TriggerEvent, len(ids)) + for _, id := range ids { + if te, ok := s.triggerEvents[id]; ok { + out[id] = te + } + } + return out, nil +} + +// mandatory_socRule builds a minimal SoC root rule + the proceeding +// type wrapper that nearly every test below needs. +func mandatorySocFixture(t *testing.T) (ProceedingType, Rule, uuid.UUID) { + t.Helper() + jurisdiction := "UPC" + procID := 1 + pt := ProceedingType{ + ID: procID, + Code: "upc.inf.cfi", + Name: "Verletzungsverfahren", + Jurisdiction: &jurisdiction, + IsActive: true, + } + socID, _ := uuid.NewRandom() + socCode := "upc.inf.cfi.soc" + procIDPtr := &procID + str := func(s string) *string { return &s } + soc := Rule{ + ID: socID, + ProceedingTypeID: procIDPtr, + ParentID: nil, + SubmissionCode: &socCode, + Name: "Klageerhebung", + NameEN: "SoC", + PrimaryParty: str("claimant"), + DurationValue: 0, + DurationUnit: "months", + Timing: str("after"), + SequenceOrder: 0, + IsActive: true, + LifecycleState: "published", + Priority: "mandatory", + } + return pt, soc, socID +} + +// TestCalculate_TriggerEventIDWithoutAnchor_Suppressed verifies the +// RoP.109.5 bug from youpcorg#2568: a rule with trigger_event_id and +// no parent_id must NOT fall back to the proceeding's trigger date. +// The buggy behaviour rendered the rule with a fabricated date 2 weeks +// before the user's SoC date. +func TestCalculate_TriggerEventIDWithoutAnchor_Suppressed(t *testing.T) { + ctx := context.Background() + pt, soc, _ := mandatorySocFixture(t) + + str := func(s string) *string { return &s } + procIDPtr := &pt.ID + ruleID, _ := uuid.NewRandom() + ruleCode := "upc.inf.cfi.rop_109_5" + rop109_5Trigger := int64(49) + rop109_5 := Rule{ + ID: ruleID, + ProceedingTypeID: procIDPtr, + ParentID: nil, + SubmissionCode: &ruleCode, + Name: "Vorbereitung mündliche Verhandlung", + NameEN: "Oral hearing preparation", + PrimaryParty: str("both"), + DurationValue: 2, + DurationUnit: "weeks", + Timing: str("before"), + SequenceOrder: 100, + IsActive: true, + LifecycleState: "published", + Priority: "mandatory", + TriggerEventID: &rop109_5Trigger, + } + + cat := &stubCatalogWithTriggers{ + stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, rop109_5}}, + triggerEvents: map[int64]TriggerEvent{ + 49: {ID: 49, Code: "oral_hearing", Name: "Oral hearing", NameDE: "Mündliche Verhandlung"}, + }, + } + + timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{}) + if err != nil { + t.Fatalf("Calculate: %v", err) + } + + byCode := make(map[string]TimelineEntry, len(timeline.Deadlines)) + for _, d := range timeline.Deadlines { + byCode[d.Code] = d + } + + rop, ok := byCode[ruleCode] + if !ok { + t.Fatalf("RoP.109.5 missing from timeline; want present with conditional-no-date") + } + if rop.DueDate != "" { + t.Errorf("RoP.109.5: DueDate=%q, want empty (no oral_hearing anchor supplied)", rop.DueDate) + } + if !rop.IsConditional { + t.Errorf("RoP.109.5: IsConditional=%v, want true", rop.IsConditional) + } + if timeline.RulesAwaitingAnchor != 1 { + t.Errorf("RulesAwaitingAnchor=%d, want 1", timeline.RulesAwaitingAnchor) + } +} + +// TestCalculate_TriggerEventIDWithAnchor_Renders verifies the +// caller-supplied trigger-event anchor produces correct arithmetic. +// 2 weeks before 2026-10-15 = 2026-10-01. +func TestCalculate_TriggerEventIDWithAnchor_Renders(t *testing.T) { + ctx := context.Background() + pt, soc, _ := mandatorySocFixture(t) + + str := func(s string) *string { return &s } + procIDPtr := &pt.ID + ruleID, _ := uuid.NewRandom() + ruleCode := "upc.inf.cfi.rop_109_5" + rop109_5Trigger := int64(49) + rop109_5 := Rule{ + ID: ruleID, + ProceedingTypeID: procIDPtr, + ParentID: nil, + SubmissionCode: &ruleCode, + Name: "Vorbereitung mündliche Verhandlung", + NameEN: "Oral hearing preparation", + PrimaryParty: str("both"), + DurationValue: 2, + DurationUnit: "weeks", + Timing: str("before"), + SequenceOrder: 100, + IsActive: true, + LifecycleState: "published", + Priority: "mandatory", + TriggerEventID: &rop109_5Trigger, + } + + cat := &stubCatalogWithTriggers{ + stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, rop109_5}}, + triggerEvents: map[int64]TriggerEvent{ + 49: {ID: 49, Code: "oral_hearing", Name: "Oral hearing", NameDE: "Mündliche Verhandlung"}, + }, + } + + opts := CalcOptions{ + TriggerEventAnchors: map[string]string{ + "oral_hearing": "2026-10-15", + }, + } + timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{}) + if err != nil { + t.Fatalf("Calculate: %v", err) + } + + byCode := make(map[string]TimelineEntry, len(timeline.Deadlines)) + for _, d := range timeline.Deadlines { + byCode[d.Code] = d + } + + rop := byCode[ruleCode] + if rop.DueDate != "2026-10-01" { + t.Errorf("RoP.109.5: DueDate=%q, want 2026-10-01 (2 weeks before oral_hearing 2026-10-15)", rop.DueDate) + } + if rop.IsConditional { + t.Errorf("RoP.109.5: IsConditional=%v, want false (anchor supplied)", rop.IsConditional) + } + if timeline.RulesAwaitingAnchor != 0 { + t.Errorf("RulesAwaitingAnchor=%d, want 0", timeline.RulesAwaitingAnchor) + } +} + +// TestCalculate_MandatoryRule_RendersByDefault is the control case for +// the optional-suppression fix: mandatory rules render with their +// computed dates by default. Prevents regression where the optional +// filter accidentally drops mandatory rules too. +func TestCalculate_MandatoryRule_RendersByDefault(t *testing.T) { + ctx := context.Background() + pt, soc, socID := mandatorySocFixture(t) + + str := func(s string) *string { return &s } + procIDPtr := &pt.ID + replyID, _ := uuid.NewRandom() + replyCode := "upc.inf.cfi.reply" + reply := Rule{ + ID: replyID, + ProceedingTypeID: procIDPtr, + ParentID: &socID, + SubmissionCode: &replyCode, + Name: "Klageerwiderung", + NameEN: "Reply to SoC", + PrimaryParty: str("defendant"), + DurationValue: 3, + DurationUnit: "months", + Timing: str("after"), + SequenceOrder: 10, + IsActive: true, + LifecycleState: "published", + Priority: "mandatory", + } + + cat := &stubCatalogWithTriggers{ + stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, reply}}, + } + + timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{}) + if err != nil { + t.Fatalf("Calculate: %v", err) + } + + byCode := make(map[string]TimelineEntry, len(timeline.Deadlines)) + for _, d := range timeline.Deadlines { + byCode[d.Code] = d + } + + got, ok := byCode[replyCode] + if !ok { + t.Fatalf("mandatory reply rule missing from default timeline") + } + if got.DueDate != "2026-08-26" { + t.Errorf("reply: DueDate=%q, want 2026-08-26 (3 months after SoC)", got.DueDate) + } +} + +// TestCalculate_OptionalRule_SuppressedByDefault pins the +// youpcorg#2570 fix: priority='optional' rules don't render in the +// default timeline. The caller opts in via IncludeOptional=true. +func TestCalculate_OptionalRule_SuppressedByDefault(t *testing.T) { + ctx := context.Background() + pt, soc, socID := mandatorySocFixture(t) + + str := func(s string) *string { return &s } + procIDPtr := &pt.ID + confID, _ := uuid.NewRandom() + confCode := "upc.inf.cfi.rop_262_2" + conf := Rule{ + ID: confID, + ProceedingTypeID: procIDPtr, + ParentID: &socID, + SubmissionCode: &confCode, + Name: "Erwiderung Vertraulichkeitsantrag", + NameEN: "Reply to confidentiality motion", + PrimaryParty: str("both"), + DurationValue: 14, + DurationUnit: "days", + Timing: str("after"), + SequenceOrder: 20, + IsActive: true, + LifecycleState: "published", + Priority: "optional", + } + + cat := &stubCatalogWithTriggers{ + stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, conf}}, + } + + timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{}) + if err != nil { + t.Fatalf("Calculate: %v", err) + } + + for _, d := range timeline.Deadlines { + if d.Code == confCode { + t.Errorf("optional R.262(2) rule rendered in default timeline (DueDate=%q); want suppressed", d.DueDate) + } + } +} + +// TestCalculate_OptionalRule_RendersWithIncludeOptional verifies the +// opt-in path: when the caller passes IncludeOptional=true, optional +// rules show up in the timeline with their computed dates. +func TestCalculate_OptionalRule_RendersWithIncludeOptional(t *testing.T) { + ctx := context.Background() + pt, soc, socID := mandatorySocFixture(t) + + str := func(s string) *string { return &s } + procIDPtr := &pt.ID + confID, _ := uuid.NewRandom() + confCode := "upc.inf.cfi.rop_262_2" + conf := Rule{ + ID: confID, + ProceedingTypeID: procIDPtr, + ParentID: &socID, + SubmissionCode: &confCode, + Name: "Erwiderung Vertraulichkeitsantrag", + NameEN: "Reply to confidentiality motion", + PrimaryParty: str("both"), + DurationValue: 14, + DurationUnit: "days", + Timing: str("after"), + SequenceOrder: 20, + IsActive: true, + LifecycleState: "published", + Priority: "optional", + } + + cat := &stubCatalogWithTriggers{ + stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, conf}}, + } + + timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{IncludeOptional: true}, cat, noOpHolidays{}, fixedCourts{}) + if err != nil { + t.Fatalf("Calculate: %v", err) + } + + byCode := make(map[string]TimelineEntry, len(timeline.Deadlines)) + for _, d := range timeline.Deadlines { + byCode[d.Code] = d + } + + got, ok := byCode[confCode] + if !ok { + t.Fatalf("optional R.262(2) missing from timeline despite IncludeOptional=true") + } + // R.262(2) is the "optional opposing-side" pattern (priority=optional, + // primary_party=both, parent=SoC root) — the engine renders this as + // IsConditional (no concrete date) per the t-paliad-289 logic + // preserved in the walk. The point of this test is that the rule + // is no longer suppressed wholesale by the t-paliad-342 default — + // it surfaces, just with the conditional-render UX. + if !got.IsConditional { + t.Errorf("R.262(2): IsConditional=%v, want true (optional opposing-side, no real trigger recorded)", got.IsConditional) + } +} + +// TestCalculate_TriggerEventID_MalformedAnchor_Errors ensures +// malformed dates in TriggerEventAnchors fail fast at the top of the +// engine, before any rule walking — same protocol as AnchorOverrides. +func TestCalculate_TriggerEventID_MalformedAnchor_Errors(t *testing.T) { + ctx := context.Background() + pt, soc, _ := mandatorySocFixture(t) + + cat := &stubCatalogWithTriggers{ + stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc}}, + } + + opts := CalcOptions{ + TriggerEventAnchors: map[string]string{ + "oral_hearing": "15-10-2026", // wrong format + }, + } + _, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{}) + if err == nil { + t.Fatalf("Calculate: want error on malformed TriggerEventAnchors date, got nil") + } +} diff --git a/pkg/litigationplanner/types.go b/pkg/litigationplanner/types.go index 1c715e5..19f393e 100644 --- a/pkg/litigationplanner/types.go +++ b/pkg/litigationplanner/types.go @@ -334,6 +334,25 @@ type CalcOptions struct { // filter applied) so a stale frontend chip doesn't break the // timeline render — see IsValidAppealTarget. AppealTarget string + + // IncludeOptional surfaces rules with priority='optional' in the + // default timeline (t-paliad-342 / youpcorg#2570). Default false: + // optional rules don't auto-fire alongside mandatory ones. The + // caller (paliad /tools/procedures, youpc.org/deadlines) wires this + // to a user-facing "show optional applications" toggle. + IncludeOptional bool + + // TriggerEventAnchors supplies concrete dates for procedural events + // referenced by rules' trigger_event_id (t-paliad-342 / youpcorg#2568). + // Key = paliad.trigger_events.code (e.g. "oral_hearing"), value = + // YYYY-MM-DD. When a rule carries an explicit trigger_event_id, that + // catalog event is the authoritative semantic anchor: arithmetic + // resolves against TriggerEventAnchors[code] if set, otherwise the + // rule is suppressed as IsConditional (no fabricated date off the + // user's trigger date). Empty map = engine never anchors on a + // trigger event, so every rule with trigger_event_id surfaces as + // conditional. + TriggerEventAnchors map[string]string } // ProjectHint scopes a Catalog call to a specific project. Paliad's @@ -375,6 +394,13 @@ type Timeline struct { TriggerEventLabel string `json:"triggerEventLabel,omitempty"` TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"` HiddenCount int `json:"hiddenCount"` + // RulesAwaitingAnchor counts rules suppressed because their + // trigger_event_id anchor date wasn't supplied via + // CalcOptions.TriggerEventAnchors (t-paliad-342). Such rules still + // render in the timeline as IsConditional (no date) — the field + // gives the caller a single integer for "N rules waiting on an + // anchor" UI affordances + telemetry. + RulesAwaitingAnchor int `json:"rulesAwaitingAnchor,omitempty"` } // TimelineEntry matches the frontend's CalculatedDeadline TypeScript