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 } // LoadScenarios returns an empty slice. The snapshot catalog has no // scenarios — youpc.org (the consumer today) doesn't carry a project / // user model. Future snapshot variants could ship demo scenarios, but // v1 returns nothing. func (c *SnapshotCatalog) LoadScenarios(_ context.Context, _ lp.ScenarioFilter) ([]lp.Scenario, error) { return []lp.Scenario{}, nil } // MatchScenario always returns ErrUnknownScenario — the snapshot has // no scenarios to match against. func (c *SnapshotCatalog) MatchScenario(_ context.Context, _ uuid.UUID) (*lp.Scenario, error) { return nil, lp.ErrUnknownScenario } // 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")