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)
302 lines
8.8 KiB
Go
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")
|