feat(litigationplanner): embedded UPC snapshot + generator (Slice C, m/paliad#124 §19)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled

Lays the foundation for youpc.org's cross-repo integration: an
in-package UPC subset of paliad's deadline corpus, embedded as JSON,
that any consumer can use to run the litigationplanner engine without
DB access.

Generator (cmd/gen-upc-snapshot):
  - Reads paliad's live DB (DATABASE_URL), applies pending migrations
    to match schema HEAD, SELECTs the UPC subset
    (proceeding_types WHERE jurisdiction='UPC' AND is_active=true,
    deadline_rules WHERE lifecycle_state='published' AND is_active=true
    on those proceedings, referenced trigger_events, DE+UPC holidays,
    UPC courts).
  - Writes pretty-printed JSON to
    pkg/litigationplanner/embedded/upc/{proceeding_types, rules,
    trigger_events, holidays, courts, meta}.json.
  - Idempotent — same DB state → same output (modulo
    meta.generated_at + auto-versioned suffix).
  - Date-stamped versioning (YYYY-MM-DD-N) with same-day suffix bump.
  - Operator runbook in cmd/gen-upc-snapshot/README.md.

Embedded subpackage (pkg/litigationplanner/embedded/upc/):
  - embed.go    — //go:embed *.json + LoadMeta()
  - snapshot.go — SnapshotCatalog (full lp.Catalog impl: LoadProceeding
    / LoadProceedingByID / LoadRuleByID / LoadRuleByCode /
    LoadRulesByTriggerEvent / LoadTriggerEventsByIDs / LookupEvents);
    O(1) map lookups; LookupEvents linear over the < 100-row UPC corpus.
  - holidays.go — SnapshotHolidayCalendar implementing lp.HolidayCalendar
    (IsNonWorkingDay / Adjust* with structured AdjustmentReason).
  - courts.go   — SnapshotCourtRegistry implementing lp.CourtRegistry.
  - Compile-time assertions (_ lp.X = (*Snapshot*)(nil)) catch
    interface drift.

Wire-up for consumers:
  cat, _ := upc.NewCatalog()
  hc, _  := upc.NewHolidayCalendar()
  cr, _  := upc.NewCourtRegistry()
  timeline, _ := lp.Calculate(ctx, "upc.inf.cfi", "2026-05-26",
                              lp.CalcOptions{}, cat, hc, cr)

Tests (snapshot_test.go, all DB-free):
  - meta parses cleanly, non-zero counts
  - LoadProceeding(upc.inf.cfi) returns expected proc + rules
  - LoadProceeding(unknown) returns ErrUnknownProceedingType
  - LookupEvents(Jurisdiction:UPC, all-following) covers corpus
  - LookupEvents(party=defendant, next) scopes anchors correctly
  - engine end-to-end via lp.Calculate against the embedded snapshot
  - holiday calendar (weekends, DE closures, UPC vacation block)
  - court registry (empty courtID fallback, known + unknown court)

Placeholder data shipped (2 proceedings, 2 rules, 5 holidays, 2
courts) so tests run without a live DB. Operator regenerates against
prod via `make snapshot-upc` once migrations 134 (B1) and 135 (B3)
have landed on prod — see cmd/gen-upc-snapshot/README.md for the
runbook. The placeholder's meta.version is suffixed `-placeholder`
to make the regeneration delta obvious.

Makefile target:
  make snapshot-upc — wraps the generator + reruns the snapshot tests

Design (§19 of docs/design-litigation-planner-2026-05-26.md):
  - Embedding format: go:embed JSON (diff-friendly, no compile coupling)
  - Generator entry: cmd/gen-upc-snapshot/main.go (idiomatic Go cmd path)
  - Versioning: meta.json carries semver + generated_at + paliad_commit
  - Regeneration: manual via Make target or `go generate`; no CI cron in v1
  - Out of scope: snapshot signing, DE/EPA/DPMA snapshots, snapshot
    diff tooling

Acceptance:
  - go build clean, go test all green (incl. 6 new tests in
    pkg/litigationplanner/embedded/upc, all DB-free)
  - SnapshotCatalog passes the compile-time lp.Catalog assertion
  - Generator binary builds + runs (Idempotence verified by re-running
    against the same source data)
This commit is contained in:
mAi
2026-05-26 15:09:37 +02:00
parent 6f8b4eabb1
commit ce28ea972e
15 changed files with 1567 additions and 1 deletions

View File

@@ -0,0 +1,216 @@
package upc
import (
"time"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// SnapshotHoliday is the embedded holiday row shape. Mirrors
// paliad.holidays + the generator's output. Country and Regime are
// optional pointers — at least one of them is non-empty on every
// row (matches paliad's CHECK).
type SnapshotHoliday struct {
Date string `json:"date"` // YYYY-MM-DD
Name string `json:"name"`
Country *string `json:"country,omitempty"`
Regime *string `json:"regime,omitempty"`
State *string `json:"state,omitempty"`
HolidayType string `json:"holiday_type"`
}
func (h SnapshotHoliday) appliesTo(country, regime string) bool {
if h.Country != nil && country != "" && *h.Country == country {
return true
}
if h.Regime != nil && regime != "" && *h.Regime == regime {
return true
}
return false
}
func (h SnapshotHoliday) isVacation() bool { return h.HolidayType == "vacation" }
func (h SnapshotHoliday) isClosure() bool { return h.HolidayType == "closure" }
// SnapshotHolidayCalendar serves HolidayCalendar against the embedded
// holiday slice. The semantics mirror paliad's HolidayService:
//
// - IsNonWorkingDay = weekend OR a closure/vacation row matching
// the (country, regime) pair
// - AdjustForNonWorkingDays = walk forward day-by-day until
// IsNonWorkingDay returns false (bounded at 60 iters)
// - AdjustForNonWorkingDaysBackward = same but stepping -1 day
// - AdjustForNonWorkingDaysWithReason = forward walk + structured
// reason payload (vacation > public_holiday > weekend)
type SnapshotHolidayCalendar struct {
byDate map[string][]SnapshotHoliday // keyed by YYYY-MM-DD
}
// NewHolidayCalendar parses the embedded holidays.json and returns a
// ready-to-use calendar.
func NewHolidayCalendar() (*SnapshotHolidayCalendar, error) {
var holidays []SnapshotHoliday
if err := readJSON("holidays.json", &holidays); err != nil {
return nil, err
}
cal := &SnapshotHolidayCalendar{byDate: make(map[string][]SnapshotHoliday, len(holidays))}
for _, h := range holidays {
cal.byDate[h.Date] = append(cal.byDate[h.Date], h)
}
return cal, nil
}
// IsNonWorkingDay returns true on weekends or closure/vacation
// holidays applicable to the given country/regime.
func (c *SnapshotHolidayCalendar) IsNonWorkingDay(date time.Time, country, regime string) bool {
if wd := date.Weekday(); wd == time.Saturday || wd == time.Sunday {
return true
}
key := date.Format("2006-01-02")
for _, h := range c.byDate[key] {
if !h.appliesTo(country, regime) {
continue
}
if h.isClosure() || h.isVacation() {
return true
}
}
return false
}
func (c *SnapshotHolidayCalendar) holidayMatch(date time.Time, country, regime string) *SnapshotHoliday {
key := date.Format("2006-01-02")
for _, h := range c.byDate[key] {
if !h.appliesTo(country, regime) {
continue
}
hh := h
return &hh
}
return nil
}
// AdjustForNonWorkingDays walks forward until the date lands on a
// working day. Bound = 60 iters (same as paliad — generous safety
// margin past any vacation run).
func (c *SnapshotHolidayCalendar) AdjustForNonWorkingDays(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool) {
original = date
adjusted = date
for i := 0; i < 60 && c.IsNonWorkingDay(adjusted, country, regime); i++ {
adjusted = adjusted.AddDate(0, 0, 1)
wasAdjusted = true
}
return adjusted, original, wasAdjusted
}
// AdjustForNonWorkingDaysBackward walks backward until the date lands
// on a working day. Same bound.
func (c *SnapshotHolidayCalendar) AdjustForNonWorkingDaysBackward(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool) {
original = date
adjusted = date
for i := 0; i < 60 && c.IsNonWorkingDay(adjusted, country, regime); i++ {
adjusted = adjusted.AddDate(0, 0, -1)
wasAdjusted = true
}
return adjusted, original, wasAdjusted
}
// AdjustForNonWorkingDaysWithReason is the structured-explanation
// counterpart to AdjustForNonWorkingDays. Reason kind precedence
// (longest cause wins): vacation > public_holiday > weekend. Reason
// is nil when wasAdjusted is false.
func (c *SnapshotHolidayCalendar) AdjustForNonWorkingDaysWithReason(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool, reason *lp.AdjustmentReason) {
original = date
adjusted = date
var holidaysHit []lp.HolidayDTO
seen := map[string]bool{}
var sawWeekend, sawVacation, sawPublicHoliday bool
var vacationName string
for i := 0; i < 60 && c.IsNonWorkingDay(adjusted, country, regime); i++ {
if wd := adjusted.Weekday(); wd == time.Saturday || wd == time.Sunday {
sawWeekend = true
}
if h := c.holidayMatch(adjusted, country, regime); h != nil {
if h.isVacation() {
sawVacation = true
if vacationName == "" {
vacationName = h.Name
}
} else if h.isClosure() {
sawPublicHoliday = true
}
key := h.Date + "|" + h.Name
if !seen[key] {
holidaysHit = append(holidaysHit, lp.HolidayDTO{
Date: h.Date,
Name: h.Name,
IsVacation: h.isVacation(),
IsClosure: h.isClosure(),
})
seen[key] = true
}
}
adjusted = adjusted.AddDate(0, 0, 1)
wasAdjusted = true
}
if !wasAdjusted {
return adjusted, original, false, nil
}
r := &lp.AdjustmentReason{Holidays: holidaysHit}
switch {
case sawVacation:
r.Kind = "vacation"
r.VacationName = vacationName
if vs, ve, ok := c.findVacationBlock(original, country, regime); ok {
r.VacationStart = vs.Format("2006-01-02")
r.VacationEnd = ve.Format("2006-01-02")
}
case sawPublicHoliday:
r.Kind = "public_holiday"
default:
r.Kind = "weekend"
}
if sawWeekend && r.Kind == "weekend" {
r.OriginalWeekday = original.Weekday().String()
}
return adjusted, original, true, r
}
// findVacationBlock scans outward from date through non-working days
// to locate the first/last IsVacation entries. Weekends inside the
// run are traversed but don't extend the reported span — start/end
// are always real vacation entries.
func (c *SnapshotHolidayCalendar) findVacationBlock(date time.Time, country, regime string) (start, end time.Time, ok bool) {
cur := date
for i := 0; i < 60; i++ {
if !c.IsNonWorkingDay(cur, country, regime) {
break
}
if h := c.holidayMatch(cur, country, regime); h != nil && h.isVacation() {
start = cur
ok = true
break
}
cur = cur.AddDate(0, 0, -1)
}
if !ok {
return
}
cur = date
for i := 0; i < 60; i++ {
if !c.IsNonWorkingDay(cur, country, regime) {
break
}
if h := c.holidayMatch(cur, country, regime); h != nil && h.isVacation() {
end = cur
}
cur = cur.AddDate(0, 0, 1)
}
return start, end, true
}
// Compile-time assertion that SnapshotHolidayCalendar satisfies
// lp.HolidayCalendar.
var _ lp.HolidayCalendar = (*SnapshotHolidayCalendar)(nil)