Files
paliad/pkg/litigationplanner/embedded/upc/snapshot.go
mAi fb0c849fc4
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
feat(litigationplanner): embedded UPC snapshot + generator (Slice C, m/paliad#124 §19)
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)
2026-05-26 15:09:37 +02:00

302 lines
8.8 KiB
Go

package upc
import (
"context"
"fmt"
"github.com/google/uuid"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// SnapshotCatalog is the embedded-JSON implementation of lp.Catalog.
// All lookups are O(1) on indexed in-memory maps; LookupEvents does a
// linear scan of the rule slice (< 100 rows in the UPC corpus, no
// index needed).
//
// ProjectHint is ignored — the snapshot has no project-scoped rules.
// applies_to_target (B1) and condition_expr (Phase 2) ride along on
// each Rule as ordinary fields; the engine consumes them identically
// whether the catalog is paliad-backed or snapshot-backed.
type SnapshotCatalog struct {
procs []lp.ProceedingType
rules []lp.Rule
triggerByID map[int64]lp.TriggerEvent
rulesByProc map[int][]lp.Rule
ruleByID map[uuid.UUID]lp.Rule
procByID map[int]lp.ProceedingType
procByCode map[string]lp.ProceedingType
rulesByTriggr map[int64][]lp.Rule
}
// NewCatalog parses the embedded snapshot and returns a ready-to-use
// Catalog. Returns an error when the JSON is missing or malformed
// (e.g. snapshot never generated, or stale relative to the package
// types).
func NewCatalog() (*SnapshotCatalog, error) {
var procs []lp.ProceedingType
if err := readJSON("proceeding_types.json", &procs); err != nil {
return nil, err
}
var rules []lp.Rule
if err := readJSON("rules.json", &rules); err != nil {
return nil, err
}
var triggers []lp.TriggerEvent
if err := readJSON("trigger_events.json", &triggers); err != nil {
return nil, err
}
c := &SnapshotCatalog{
procs: procs,
rules: rules,
triggerByID: make(map[int64]lp.TriggerEvent, len(triggers)),
rulesByProc: make(map[int][]lp.Rule),
ruleByID: make(map[uuid.UUID]lp.Rule, len(rules)),
procByID: make(map[int]lp.ProceedingType, len(procs)),
procByCode: make(map[string]lp.ProceedingType, len(procs)),
rulesByTriggr: make(map[int64][]lp.Rule),
}
for _, p := range procs {
c.procByID[p.ID] = p
c.procByCode[p.Code] = p
}
for _, r := range rules {
c.ruleByID[r.ID] = r
if r.ProceedingTypeID != nil {
c.rulesByProc[*r.ProceedingTypeID] = append(c.rulesByProc[*r.ProceedingTypeID], r)
}
if r.TriggerEventID != nil {
c.rulesByTriggr[*r.TriggerEventID] = append(c.rulesByTriggr[*r.TriggerEventID], r)
}
}
for _, t := range triggers {
c.triggerByID[t.ID] = t
}
return c, nil
}
// LoadProceeding returns the proceeding-type metadata + rules. The
// ProjectHint is ignored on the snapshot side (no projects).
func (c *SnapshotCatalog) LoadProceeding(_ context.Context, code string, _ lp.ProjectHint) (*lp.ProceedingType, []lp.Rule, error) {
p, ok := c.procByCode[code]
if !ok {
return nil, nil, lp.ErrUnknownProceedingType
}
// Return a defensive copy of the rule slice so callers can sort /
// mutate without leaking back into the cache.
src := c.rulesByProc[p.ID]
dst := make([]lp.Rule, len(src))
copy(dst, src)
return &p, dst, nil
}
// LoadProceedingByID is the resolver used by CalculateRule.
func (c *SnapshotCatalog) LoadProceedingByID(_ context.Context, id int) (*lp.ProceedingType, error) {
p, ok := c.procByID[id]
if !ok {
return nil, lp.ErrUnknownProceedingType
}
return &p, nil
}
// LoadRuleByID resolves a rule UUID to the rule row.
func (c *SnapshotCatalog) LoadRuleByID(_ context.Context, ruleID string) (*lp.Rule, error) {
id, err := uuid.Parse(ruleID)
if err != nil {
return nil, lp.ErrUnknownRule
}
r, ok := c.ruleByID[id]
if !ok {
return nil, lp.ErrUnknownRule
}
return &r, nil
}
// LoadRuleByCode resolves a rule by (proceedingCode, submissionCode).
func (c *SnapshotCatalog) LoadRuleByCode(_ context.Context, proceedingCode, submissionCode string) (*lp.Rule, *lp.ProceedingType, error) {
p, ok := c.procByCode[proceedingCode]
if !ok {
return nil, nil, lp.ErrUnknownProceedingType
}
for _, r := range c.rulesByProc[p.ID] {
if r.SubmissionCode != nil && *r.SubmissionCode == submissionCode {
rr := r
pp := p
return &rr, &pp, nil
}
}
return nil, nil, lp.ErrUnknownRule
}
// LoadRulesByTriggerEvent lists Pipeline-C trigger-event-rooted rules.
func (c *SnapshotCatalog) LoadRulesByTriggerEvent(_ context.Context, triggerEventID int64) ([]lp.Rule, error) {
src := c.rulesByTriggr[triggerEventID]
dst := make([]lp.Rule, len(src))
copy(dst, src)
return dst, nil
}
// LoadTriggerEventsByIDs returns trigger-event rows for the given IDs.
func (c *SnapshotCatalog) LoadTriggerEventsByIDs(_ context.Context, ids []int64) (map[int64]lp.TriggerEvent, error) {
out := make(map[int64]lp.TriggerEvent, len(ids))
for _, id := range ids {
if t, ok := c.triggerByID[id]; ok {
out[id] = t
}
}
return out, nil
}
// LookupEvents runs the multi-axis filter + depth walk against the
// in-memory rule slice. Mirrors the paliad-side semantics: unknown
// axis values fall through as "no filter on this axis"; anchors are
// depth=1, walked-in children are depth=2+; results ordered by
// (proceeding_type_id, sequence_order).
func (c *SnapshotCatalog) LookupEvents(_ context.Context, axes lp.EventLookupAxes, depth lp.EventLookupDepth) ([]lp.EventMatch, error) {
// Validate axes; unknown values reset to empty (no filter).
jurisdiction := axes.Jurisdiction
if jurisdiction != "" && jurisdiction != "UPC" && jurisdiction != "DE" &&
jurisdiction != "EPA" && jurisdiction != "DPMA" {
jurisdiction = ""
}
party := axes.Party
if party != "" && !lp.IsValidPrimaryParty(party) {
party = ""
}
appealTarget := axes.AppealTarget
if appealTarget != "" && !lp.IsValidAppealTarget(appealTarget) {
appealTarget = ""
}
// First pass: find anchor matches (rules that satisfy every
// non-zero axis directly).
anchors := make(map[uuid.UUID]bool, len(c.rules))
for _, r := range c.rules {
if r.ProceedingTypeID == nil {
continue
}
p := c.procByID[*r.ProceedingTypeID]
if jurisdiction != "" && (p.Jurisdiction == nil || *p.Jurisdiction != jurisdiction) {
continue
}
if axes.ProceedingTypeID != nil && *r.ProceedingTypeID != *axes.ProceedingTypeID {
continue
}
if party != "" && (r.PrimaryParty == nil || *r.PrimaryParty != party) {
continue
}
// EventCategoryID axis: the embedded snapshot doesn't carry
// the deadline_concept_event_types junction (only paliad has
// it). When EventCategoryID is set, we conservatively return
// no matches — youpc.org doesn't use this axis today. Future
// snapshot generations can add a concept→category index if
// needed.
if axes.EventCategoryID != nil {
continue
}
if appealTarget != "" {
found := false
for _, t := range r.AppliesToTarget {
if t == appealTarget {
found = true
break
}
}
if !found {
continue
}
}
anchors[r.ID] = true
}
// Second pass: depth walk. Expand anchors → their immediate
// children (parent_id ∈ matched). Iterate to fixpoint for
// EventLookupDepthAllFollowing; stop after one pass for
// EventLookupDepthNext.
matched := make(map[uuid.UUID]bool, len(anchors))
for id := range anchors {
matched[id] = true
}
if depth == lp.EventLookupDepthNext || depth == lp.EventLookupDepthAllFollowing {
for {
grew := false
for _, r := range c.rules {
if matched[r.ID] {
continue
}
if r.ParentID == nil {
continue
}
if matched[*r.ParentID] {
matched[r.ID] = true
grew = true
}
}
if !grew || depth == lp.EventLookupDepthNext {
break
}
}
}
// Compute depth from anchor: walk parent_id chain until we hit
// an anchor.
depths := make(map[uuid.UUID]int, len(matched))
for id := range matched {
if anchors[id] {
depths[id] = 1
continue
}
// Walk up.
d := 1
cur := id
maxIter := len(matched) + 1
for i := 0; i < maxIter; i++ {
r, ok := c.ruleByID[cur]
if !ok || r.ParentID == nil {
break
}
d++
cur = *r.ParentID
if anchors[cur] {
break
}
}
depths[id] = d
}
// Compose output, ordered by (proceeding_type_id, sequence_order)
// via the catalog's rule slice ordering.
out := make([]lp.EventMatch, 0, len(matched))
for _, r := range c.rules {
if !matched[r.ID] {
continue
}
var parentRuleID *uuid.UUID
if r.ParentID != nil && matched[*r.ParentID] {
p := *r.ParentID
parentRuleID = &p
}
proc := lp.ProceedingType{}
if r.ProceedingTypeID != nil {
proc = c.procByID[*r.ProceedingTypeID]
}
out = append(out, lp.EventMatch{
Rule: r,
ProceedingType: proc,
Priority: r.Priority,
DepthFromAnchor: depths[r.ID],
ParentRuleID: parentRuleID,
})
}
return out, nil
}
// Compile-time assertion that SnapshotCatalog satisfies lp.Catalog.
var _ lp.Catalog = (*SnapshotCatalog)(nil)
// ErrSnapshotEmpty is returned by NewCatalog when the embedded files
// parse but the corpus is empty (zero proceedings) — almost always a
// sign that the snapshot has never been generated.
var ErrSnapshotEmpty = fmt.Errorf("upc snapshot is empty — run cmd/gen-upc-snapshot")