fix(litigationplanner): R.109.1/R.109.4 mis-anchor + duplicate 'both' row in columns view (t-paliad-304, m/paliad#135)

Two bugs surfaced on /tools/verfahrensablauf?side=defendant for upc.inf.cfi:

1. Anchor regression for timing='before' children of court-set parents.
   Rules R.109.1 (translation_request) and R.109.4 (interpreter_cost)
   anchor on the oral hearing (parent_id=upc.inf.cfi.oral, IsCourtSet)
   but were computing dates BEFORE the Statement of Claim — 1 month
   resp. 2 weeks before the SoC instead of before the oral hearing.

   Root cause: engine walked rules in sequence_order, and the two
   "before"-timed children carry sequence_order 45/46 (their chronological
   position, before the oral hearing at 50). Their parent had therefore
   not been processed yet when the children were, so courtSet[oral.ID]
   was still empty → parentIsCourtSet=false → the engine fell back to
   the trigger date as the base.

   Fix: walk rules in topological order (parent-first) during the
   compute pass, then restore sequence_order on the output slice so
   the wire shape and the linear timeline view's render order stay
   identical to the legacy behaviour modulo the bug fix.

2. Duplicate "Antrag auf Simultanübersetzung" row in columns view.
   With primary_party='both' and an explicit side pick (?side=defendant),
   the bucketing mirrored the card into both 'Unsere Seite' and
   'Gegnerseite' — the same card on the same row, visible as a
   duplicate.

   Fix: when the user has committed to a perspective (side picked)
   but no appellant axis applies, collapse 'both' rows into ours.
   The '↔ beide Seiten' indicator is suppressed in that path to match
   the existing appellant-collapse semantics (no sibling row to mirror
   to). Legacy mirror behaviour is preserved when side is null.

DB audit ruled out a data-level duplicate: exactly one published+active
row per submission_code in paliad.deadline_rules.

Tests:
  - pkg/litigationplanner/before_court_set_anchor_test.go: synthetic
    rules pinning the conditional-on-court-set-parent contract plus
    the override path (1mo before user-pinned oral).
  - frontend/src/client/views/verfahrensablauf-core.test.ts: two new
    cases pinning the side-collapse routing for party='both'.
This commit is contained in:
mAi
2026-05-26 15:54:02 +02:00
parent 16ec8c490a
commit 1d704f6e04
4 changed files with 458 additions and 7 deletions

View File

@@ -327,6 +327,29 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
expect(rows[0].ours).toHaveLength(0);
});
test("side=defendant collapses 'both' rules into ours (no mirror) — m/paliad#135", () => {
// When the user has committed to a perspective via `?side=`, the
// mirror is visual noise: the same card renders twice on one row,
// once in 'Unsere Seite' and once in 'Gegnerseite'. The card's
// '↔ beide Seiten' indicator already conveys the both-parties
// semantic, so collapsing into ours is sufficient.
const rows = bucketDeadlinesIntoColumns(
[both("Antrag auf Simultanübersetzung", "2026-04-27")],
{ side: "defendant" },
);
expect(rows[0].ours.map((d) => d.name)).toEqual(["Antrag auf Simultanübersetzung"]);
expect(rows[0].opponent).toHaveLength(0);
});
test("side=claimant collapses 'both' rules into ours (no mirror) — m/paliad#135", () => {
const rows = bucketDeadlinesIntoColumns(
[both("Antrag auf Simultanübersetzung", "2026-04-27")],
{ side: "claimant" },
);
expect(rows[0].ours.map((d) => d.name)).toEqual(["Antrag auf Simultanübersetzung"]);
expect(rows[0].opponent).toHaveLength(0);
});
test("rows align across columns by dueDate so same-day events stay on one grid row", () => {
const sameDate = "2026-07-23";
const rows = bucketDeadlinesIntoColumns([

View File

@@ -698,7 +698,18 @@ export function bucketDeadlinesIntoColumns(
// Role-swap collapse: appellant initiated → both → one row
// in appellant's column. Mirror suppressed.
row[appellantColumn].push(dl);
} else if (userSide !== null) {
// Side picked but no appellant axis (first-instance Inf, Rev,
// …): the user has committed to a perspective, so the mirror
// is visual noise — the same card appears twice on the same
// row, once in "Unsere Seite" and once in "Gegnerseite".
// Collapse into ours; the "↔ beide Seiten" indicator on the
// card already conveys that the rule applies to both parties.
// (m/paliad#135 / t-paliad-304)
row.ours.push(dl);
} else {
// No perspective picked → keep the legacy mirror so neither
// axis is privileged. Pinned by the "default (no opts)" test.
row.ours.push(dl);
row.opponent.push(dl);
}
@@ -728,8 +739,14 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
// Collapsed "both" rows lose their mirror tag — there's no longer
// a sibling row to mirror to, so the "↔ beide Seiten" hint would
// be misleading. Keep it for the legacy mirror path.
const showMirrorTag = !appellantPinned;
// be misleading. Both collapse paths suppress it:
// - appellantPinned: role-swap collapse into appellant's column
// - userSide !== null without appellantPinned: perspective-locked
// collapse into ours (m/paliad#135 / t-paliad-304).
// Legacy mirror path (no side, no appellant) keeps the tag — both
// sibling rows still render so the tag has a visual referent.
const sideCollapse = userSide !== null;
const showMirrorTag = !appellantPinned && !sideCollapse;
const renderCell = (items: CalculatedDeadline[]): string => {
if (items.length === 0) {

View File

@@ -0,0 +1,322 @@
package litigationplanner
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
)
// Regression test for t-paliad-304 / m/paliad#135.
//
// Reproduces the R.109.1 / R.109.4 anchor bug on upc.inf.cfi:
// - Trigger (Klageerhebung): parent_id=nil, duration=0, !IsCourtSet, sequence_order=0
// - Translation request: parent_id=oral, duration=1mo before, sequence_order=45
// - Interpreter cost: parent_id=oral, duration=2w before, sequence_order=46
// - Oral hearing: parent_id=nil, duration=0, IsCourtSet, sequence_order=50
//
// The "before" children are listed BEFORE the oral hearing in sequence
// order (because chronologically they happen before it). The engine walks
// rules in sequence_order, so when it processes the translation/
// interpreter rows, the oral hearing has not yet been processed →
// courtSet[oral.ID] is not yet set → parentIsCourtSet is false → the
// engine falls back to the trigger date as the base. Result: the timing=
// 'before' arithmetic produces 27.04.2026 (1mo before SoC) instead of
// the conditional-no-date treatment that a court-set parent should
// trigger.
//
// Expected post-fix: translation_request + interpreter_cost render as
// IsConditional (no concrete date) because their parent's date is
// court-set and the proceeding does not yet have an explicit override.
// stubCatalog implements lp.Catalog backed by an in-memory rule slice.
// Only LoadProceeding is needed for the engine path under test; the
// other interface methods return errors so an unintended call surfaces
// immediately.
type stubCatalog struct {
pt ProceedingType
rules []Rule
}
func (s *stubCatalog) LoadProceeding(_ context.Context, code string, _ ProjectHint) (*ProceedingType, []Rule, error) {
if code != s.pt.Code {
return nil, nil, ErrUnknownProceedingType
}
rules := make([]Rule, len(s.rules))
copy(rules, s.rules)
pt := s.pt
return &pt, rules, nil
}
func (s *stubCatalog) LoadProceedingByID(_ context.Context, _ int) (*ProceedingType, error) {
return nil, errors.New("stubCatalog.LoadProceedingByID: not implemented")
}
func (s *stubCatalog) LoadRuleByID(_ context.Context, _ string) (*Rule, error) {
return nil, errors.New("stubCatalog.LoadRuleByID: not implemented")
}
func (s *stubCatalog) LoadRuleByCode(_ context.Context, _, _ string) (*Rule, *ProceedingType, error) {
return nil, nil, errors.New("stubCatalog.LoadRuleByCode: not implemented")
}
func (s *stubCatalog) LoadRulesByTriggerEvent(_ context.Context, _ int64) ([]Rule, error) {
return nil, nil
}
func (s *stubCatalog) LoadTriggerEventsByIDs(_ context.Context, _ []int64) (map[int64]TriggerEvent, error) {
return map[int64]TriggerEvent{}, nil
}
func (s *stubCatalog) LookupEvents(_ context.Context, _ EventLookupAxes, _ EventLookupDepth) ([]EventMatch, error) {
return nil, nil
}
// noOpHolidays never adjusts dates — the test fixture doesn't care about
// weekends or holidays, only about which base date the engine resolves.
type noOpHolidays struct{}
func (noOpHolidays) IsNonWorkingDay(_ time.Time, _, _ string) bool { return false }
func (noOpHolidays) AdjustForNonWorkingDays(d time.Time, _, _ string) (time.Time, time.Time, bool) {
return d, d, false
}
func (noOpHolidays) AdjustForNonWorkingDaysBackward(d time.Time, _, _ string) (time.Time, time.Time, bool) {
return d, d, false
}
func (noOpHolidays) AdjustForNonWorkingDaysWithReason(d time.Time, _, _ string) (time.Time, time.Time, bool, *AdjustmentReason) {
return d, d, false, nil
}
type fixedCourts struct{}
func (fixedCourts) CountryRegime(_, _, _ string) (string, string, error) {
return CountryDE, RegimeUPC, nil
}
func TestCalculate_BeforeChildOfCourtSetParent_OutOfOrderSequence(t *testing.T) {
ctx := context.Background()
// proceeding metadata
jurisdiction := "UPC"
procID := 1
pt := ProceedingType{
ID: procID,
Code: "upc.inf.cfi",
Name: "Verletzungsverfahren",
NameEN: "Infringement",
Jurisdiction: &jurisdiction,
IsActive: true,
}
mkID := func() uuid.UUID {
id, _ := uuid.NewRandom()
return id
}
str := func(s string) *string { return &s }
procIDPtr := &procID
socID := mkID()
oralID := mkID()
transID := mkID()
interpID := mkID()
socCode := "upc.inf.cfi.soc"
oralCode := "upc.inf.cfi.oral"
transCode := "upc.inf.cfi.translation_request"
interpCode := "upc.inf.cfi.interpreter_cost"
rules := []Rule{
{
ID: socID,
ProceedingTypeID: procIDPtr,
ParentID: nil,
SubmissionCode: &socCode,
Name: "Klageerhebung",
NameEN: "Statement of Claim",
PrimaryParty: str("claimant"),
DurationValue: 0,
DurationUnit: "months",
Timing: str("after"),
SequenceOrder: 0,
IsCourtSet: false,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
},
// Translation request: sequence_order BEFORE the oral hearing.
// Reproduces the real corpus ordering (DB rows 45 < 50).
{
ID: transID,
ProceedingTypeID: procIDPtr,
ParentID: &oralID,
SubmissionCode: &transCode,
Name: "Antrag auf Simultanübersetzung",
NameEN: "Translation request",
PrimaryParty: str("both"),
DurationValue: 1,
DurationUnit: "months",
Timing: str("before"),
SequenceOrder: 45,
IsCourtSet: false,
IsActive: true,
LifecycleState: "published",
Priority: "optional",
},
// Interpreter cost notice: sequence_order BEFORE the oral hearing.
{
ID: interpID,
ProceedingTypeID: procIDPtr,
ParentID: &oralID,
SubmissionCode: &interpCode,
Name: "Mitteilung Dolmetscherkosten",
NameEN: "Interpreter cost notice",
PrimaryParty: str("court"),
DurationValue: 2,
DurationUnit: "weeks",
Timing: str("before"),
SequenceOrder: 46,
IsCourtSet: false,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
},
// Oral hearing: court-set, no calculable date. Listed AFTER its
// "before"-timed children in sequence_order.
{
ID: oralID,
ProceedingTypeID: procIDPtr,
ParentID: nil,
SubmissionCode: &oralCode,
Name: "Mündliche Verhandlung",
NameEN: "Oral hearing",
PrimaryParty: str("court"),
DurationValue: 0,
DurationUnit: "months",
Timing: str("after"),
SequenceOrder: 50,
IsCourtSet: true,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
},
}
cat := &stubCatalog{pt: pt, rules: rules}
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
}
// The trigger event itself is unambiguous.
if got := byCode[socCode]; got.DueDate != "2026-05-26" || !got.IsRootEvent {
t.Errorf("SoC: DueDate=%q IsRootEvent=%v, want 2026-05-26 + IsRootEvent=true", got.DueDate, got.IsRootEvent)
}
// Oral hearing must surface as IsCourtSet (no date).
oral := byCode[oralCode]
if oral.DueDate != "" || !oral.IsCourtSet {
t.Errorf("oral: DueDate=%q IsCourtSet=%v, want empty + IsCourtSet=true", oral.DueDate, oral.IsCourtSet)
}
// The two "before" children of the court-set oral hearing MUST surface
// as conditional rows (no date, no fabricated arithmetic off the
// trigger date). The buggy behaviour produces 2026-04-27 and 2026-05-12.
trans := byCode[transCode]
if trans.DueDate != "" {
t.Errorf("translation_request: DueDate=%q, want empty (parent oral is court-set, no anchor known yet)", trans.DueDate)
}
if !trans.IsConditional && !trans.IsCourtSet {
t.Errorf("translation_request: IsConditional=%v IsCourtSet=%v, want at least one true", trans.IsConditional, trans.IsCourtSet)
}
interp := byCode[interpCode]
if interp.DueDate != "" {
t.Errorf("interpreter_cost: DueDate=%q, want empty (parent oral is court-set, no anchor known yet)", interp.DueDate)
}
if !interp.IsConditional && !interp.IsCourtSet {
t.Errorf("interpreter_cost: IsConditional=%v IsCourtSet=%v, want at least one true", interp.IsConditional, interp.IsCourtSet)
}
}
// TestCalculate_BeforeChildOfCourtSetParent_WithOverride pins the
// override semantics: when the user supplies an anchor override for
// the court-set parent, the "before" children should compute against
// that override date instead of remaining conditional.
func TestCalculate_BeforeChildOfCourtSetParent_WithOverride(t *testing.T) {
ctx := context.Background()
jurisdiction := "UPC"
procID := 1
pt := ProceedingType{
ID: procID,
Code: "upc.inf.cfi",
Name: "Verletzungsverfahren",
Jurisdiction: &jurisdiction,
IsActive: true,
}
mkID := func() uuid.UUID {
id, _ := uuid.NewRandom()
return id
}
str := func(s string) *string { return &s }
procIDPtr := &procID
socID := mkID()
oralID := mkID()
transID := mkID()
socCode := "upc.inf.cfi.soc"
oralCode := "upc.inf.cfi.oral"
transCode := "upc.inf.cfi.translation_request"
rules := []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",
},
{
ID: transID, ProceedingTypeID: procIDPtr, ParentID: &oralID,
SubmissionCode: &transCode, Name: "Antrag auf Simultanübersetzung", NameEN: "Translation request",
PrimaryParty: str("both"), DurationValue: 1, DurationUnit: "months", Timing: str("before"),
SequenceOrder: 45, IsActive: true, LifecycleState: "published", Priority: "optional",
},
{
ID: oralID, ProceedingTypeID: procIDPtr, ParentID: nil,
SubmissionCode: &oralCode, Name: "Mündliche Verhandlung", NameEN: "Oral hearing",
PrimaryParty: str("court"), DurationValue: 0, DurationUnit: "months", Timing: str("after"),
SequenceOrder: 50, IsCourtSet: true, IsActive: true, LifecycleState: "published", Priority: "mandatory",
},
}
cat := &stubCatalog{pt: pt, rules: rules}
// User pins the oral hearing to 2026-10-15.
opts := CalcOptions{
AnchorOverrides: map[string]string{
oralCode: "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
}
if got := byCode[oralCode].DueDate; got != "2026-10-15" {
t.Errorf("oral: DueDate=%q, want 2026-10-15 (user override)", got)
}
// 1 month before 2026-10-15 = 2026-09-15
if got := byCode[transCode].DueDate; got != "2026-09-15" {
t.Errorf("translation_request: DueDate=%q, want 2026-09-15 (1 month before oral override)", got)
}
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"sort"
"time"
"github.com/google/uuid"
@@ -183,10 +184,27 @@ func Calculate(
return nil, fmt.Errorf("load trigger events for conditional labels: %w", err)
}
// Walk the rule list in sequence_order (already sorted by the
// catalog query) and compute each entry, keeping a code→date map so
// RelativeTo / parent_id references resolve to the adjusted
// predecessor date.
// Walk the rule list in TOPOLOGICAL order (parents before children),
// not the raw sequence_order order from the catalog. The catalog
// returns rules sorted by sequence_order, which is the chronological/
// display order. That order is parent-first for the common
// timing='after' case but parent-LAST for timing='before' children
// (e.g. upc.inf.cfi.translation_request at seq=45 vs its parent
// upc.inf.cfi.oral at seq=50 — m/paliad#135). Without topological
// ordering the parent-state checks below (courtSet[parent] /
// computed[parent_code]) read stale empty maps when a child appears
// before its parent, and the engine falls back to the trigger date
// → fabricates dates before the SoC.
//
// Original sequence_order is restored at the end of the walk so the
// wire shape and the timeline view's render order stay identical to
// the legacy behaviour modulo the bug fix.
sequenceIndex := make(map[uuid.UUID]int, len(rules))
for i, r := range rules {
sequenceIndex[r.ID] = i
}
walkRules := topoSortByParentDepth(rules)
computed := make(map[string]time.Time, len(rules))
courtSet := make(map[uuid.UUID]bool, len(rules))
deadlines := make([]TimelineEntry, 0, len(rules))
@@ -197,7 +215,7 @@ func Calculate(
hiddenCount := 0
appellantContext := make(map[uuid.UUID]string, len(rules))
for _, r := range rules {
for _, r := range walkRules {
// Phase-3 unified gate: evaluate condition_expr (jsonb).
// Suppression semantic preserved: when the gate fires false
// AND no alt_* values exist, the rule is dropped from the
@@ -547,6 +565,20 @@ func Calculate(
deadlines = append(deadlines, d)
}
// Restore sequence_order on the output slice. The compute walk
// re-ordered rules topologically (parent-first) so the parent-state
// checks resolved correctly; the wire shape and the linear timeline
// view both rely on sequence_order being the surface render order.
// (m/paliad#135)
sort.SliceStable(deadlines, func(i, j int) bool {
a, errA := uuid.Parse(deadlines[i].RuleID)
b, errB := uuid.Parse(deadlines[j].RuleID)
if errA != nil || errB != nil {
return false
}
return sequenceIndex[a] < sequenceIndex[b]
})
// t-paliad-296: within consecutive runs of rules sharing the same
// trigger group (parent_id + trigger_event_id), reorder by duration
// ascending so optional events following the same anchor render in
@@ -925,3 +957,60 @@ func AllFlagsSet(required []string, set map[string]struct{}) bool {
func WireFlagsFromPriority(priority string) (isMandatory, isOptional bool) {
return wireFlagsFromPriority(priority)
}
// topoSortByParentDepth returns a copy of `rules` ordered so every rule
// appears after its parent_id ancestor. Ties (rules at the same depth)
// preserve their input order — which the catalog returns in
// sequence_order. Used by Calculate to ensure the parent-state checks
// (courtSet[parent], computed[parent_code]) see populated entries even
// when sequence_order lists a "before"-timed child BEFORE its parent
// (e.g. upc.inf.cfi.translation_request at seq=45 with parent
// upc.inf.cfi.oral at seq=50 — m/paliad#135).
//
// Rules whose parent_id is missing from the rule slice (cross-tree
// references that the per-proceeding filter dropped) are treated as
// depth 0 — they walk in their original sequence position.
//
// The algorithm is depth-via-memoised-recursion. Cycle protection: a
// rule chain that revisits a node is broken at depth 0; production
// data shouldn't contain cycles, but a corrupted catalog mustn't hang
// the calculator.
func topoSortByParentDepth(rules []Rule) []Rule {
byID := make(map[uuid.UUID]Rule, len(rules))
inSlice := make(map[uuid.UUID]bool, len(rules))
for _, r := range rules {
byID[r.ID] = r
inSlice[r.ID] = true
}
depth := make(map[uuid.UUID]int, len(rules))
var resolve func(id uuid.UUID, seen map[uuid.UUID]bool) int
resolve = func(id uuid.UUID, seen map[uuid.UUID]bool) int {
if d, ok := depth[id]; ok {
return d
}
if seen[id] {
depth[id] = 0
return 0
}
seen[id] = true
r, ok := byID[id]
if !ok || r.ParentID == nil || !inSlice[*r.ParentID] {
depth[id] = 0
return 0
}
d := resolve(*r.ParentID, seen) + 1
depth[id] = d
return d
}
for _, r := range rules {
resolve(r.ID, map[uuid.UUID]bool{})
}
out := make([]Rule, len(rules))
copy(out, rules)
sort.SliceStable(out, func(i, j int) bool {
return depth[out[i].ID] < depth[out[j].ID]
})
return out
}