Merge: t-paliad-292 — Slice C: embedded UPC snapshot + generator (m/paliad#124 §19)
This commit is contained in:
23
Makefile
23
Makefile
@@ -21,7 +21,7 @@
|
|||||||
# the test runner's working dirs. None of them touch internal/db/migrations/
|
# the test runner's working dirs. None of them touch internal/db/migrations/
|
||||||
# files.
|
# files.
|
||||||
|
|
||||||
.PHONY: help verify-migrations verify-mig verify-mig-app test test-go test-frontend refresh-snapshot
|
.PHONY: help verify-migrations verify-mig verify-mig-app test test-go test-frontend refresh-snapshot snapshot-upc
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Paliad — developer targets"
|
@echo "Paliad — developer targets"
|
||||||
@@ -33,6 +33,8 @@ help:
|
|||||||
@echo " test Short test pass — covers gate tier"
|
@echo " test Short test pass — covers gate tier"
|
||||||
@echo " test-go Full Go suite with race detector"
|
@echo " test-go Full Go suite with race detector"
|
||||||
@echo " test-frontend Frontend bun:test suite"
|
@echo " test-frontend Frontend bun:test suite"
|
||||||
|
@echo " snapshot-upc Regenerate pkg/litigationplanner/embedded/upc/ from live DB"
|
||||||
|
@echo " (needs DATABASE_URL — see cmd/gen-upc-snapshot/README.md)"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Set TEST_DATABASE_URL to enable live-DB tests. Example:"
|
@echo "Set TEST_DATABASE_URL to enable live-DB tests. Example:"
|
||||||
@echo " export TEST_DATABASE_URL=postgres://paliad:...@localhost:11833/paliad_test"
|
@echo " export TEST_DATABASE_URL=postgres://paliad:...@localhost:11833/paliad_test"
|
||||||
@@ -141,3 +143,22 @@ refresh-snapshot:
|
|||||||
' internal/db/testdata/prod-snapshot.sql.tmp > internal/db/testdata/prod-snapshot.sql
|
' internal/db/testdata/prod-snapshot.sql.tmp > internal/db/testdata/prod-snapshot.sql
|
||||||
@rm internal/db/testdata/prod-snapshot.sql.tmp
|
@rm internal/db/testdata/prod-snapshot.sql.tmp
|
||||||
@wc -l internal/db/testdata/prod-snapshot.sql
|
@wc -l internal/db/testdata/prod-snapshot.sql
|
||||||
|
|
||||||
|
# Regenerate the embedded UPC snapshot from a live paliad DB. The
|
||||||
|
# generator applies pending migrations first, then SELECTs the UPC
|
||||||
|
# subset and writes JSON files under pkg/litigationplanner/embedded/upc/.
|
||||||
|
#
|
||||||
|
# Requires DATABASE_URL — Slice C of the litigation-planner extraction
|
||||||
|
# (m/paliad#124 §19). See cmd/gen-upc-snapshot/README.md for the full
|
||||||
|
# operator runbook.
|
||||||
|
snapshot-upc:
|
||||||
|
@if [ -z "$$DATABASE_URL" ]; then \
|
||||||
|
echo "ERROR: DATABASE_URL is not set."; \
|
||||||
|
echo " Snapshot generation needs read access to a paliad DB."; \
|
||||||
|
echo " Set DATABASE_URL to the live paliad Postgres, then re-run."; \
|
||||||
|
exit 2; \
|
||||||
|
fi
|
||||||
|
@echo "==> regenerating UPC snapshot from $$DATABASE_URL"
|
||||||
|
go run ./cmd/gen-upc-snapshot
|
||||||
|
@echo "==> running snapshot tests against the regenerated data"
|
||||||
|
go test ./pkg/litigationplanner/embedded/upc/...
|
||||||
|
|||||||
59
cmd/gen-upc-snapshot/README.md
Normal file
59
cmd/gen-upc-snapshot/README.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# gen-upc-snapshot
|
||||||
|
|
||||||
|
Regenerates the embedded UPC snapshot consumed by
|
||||||
|
`pkg/litigationplanner/embedded/upc`. Slice C of the litigation-planner
|
||||||
|
extraction (m/paliad#124 §19). See
|
||||||
|
`docs/design-litigation-planner-2026-05-26.md` §19 for the full design.
|
||||||
|
|
||||||
|
## When to regenerate
|
||||||
|
|
||||||
|
After any change that affects the public UPC rule corpus:
|
||||||
|
|
||||||
|
- new rules merged via the admin rule-editor
|
||||||
|
- a deadline-rule migration that touches UPC rows
|
||||||
|
- a `paliad.holidays` update (new public holidays / vacation runs)
|
||||||
|
- a `paliad.courts` update (new UPC LD opens, etc.)
|
||||||
|
- a `paliad.proceeding_types` change for `jurisdiction = 'UPC'`
|
||||||
|
|
||||||
|
The snapshot is operator-controlled — there is no CI regeneration in v1.
|
||||||
|
|
||||||
|
## How to regenerate
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make snapshot-upc
|
||||||
|
```
|
||||||
|
|
||||||
|
or directly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
DATABASE_URL=postgres://... go run ./cmd/gen-upc-snapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|-----------------|----------------------------------------|---------|
|
||||||
|
| `-output` | `./pkg/litigationplanner/embedded/upc` | directory to write JSON files into |
|
||||||
|
| `-version` | auto-derived (`YYYY-MM-DD-N`) | override the snapshot version |
|
||||||
|
| `-source-label` | empty | text label written to `meta.json` (`paliad-prod`, `paliad-dev`, …) |
|
||||||
|
|
||||||
|
The generator:
|
||||||
|
|
||||||
|
1. Applies pending migrations against `DATABASE_URL` (snapshot always matches schema HEAD).
|
||||||
|
2. SELECTs UPC active proceeding_types + their published+active rules + referenced trigger_events + DE/UPC holidays + UPC courts.
|
||||||
|
3. Writes pretty-printed JSON to `<output>/{proceeding_types,rules,trigger_events,holidays,courts,meta}.json`.
|
||||||
|
|
||||||
|
## Idempotence
|
||||||
|
|
||||||
|
Running twice with the same DB state produces the same JSON (modulo `meta.generated_at`). Diff-friendly in git.
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
`meta.json.version` uses `YYYY-MM-DD-N` where N starts at 1 and increments on same-day regenerations. The generator reads the existing `meta.json` and bumps automatically.
|
||||||
|
|
||||||
|
## After regeneration
|
||||||
|
|
||||||
|
1. Review the diff: `git diff pkg/litigationplanner/embedded/upc/`.
|
||||||
|
2. Run tests: `go test ./pkg/litigationplanner/embedded/upc/...`.
|
||||||
|
3. Commit with a message like `chore(snapshot): regenerate UPC snapshot (<reason>)`.
|
||||||
|
4. Notify any downstream consumer (youpc.org) that a new paliad release is available.
|
||||||
301
cmd/gen-upc-snapshot/main.go
Normal file
301
cmd/gen-upc-snapshot/main.go
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
// Command gen-upc-snapshot reads paliad's live deadline corpus and
|
||||||
|
// writes the UPC subset as JSON files under
|
||||||
|
// pkg/litigationplanner/embedded/upc/. The package's embedded
|
||||||
|
// catalog/holiday/court implementations then serve this data without
|
||||||
|
// any DB roundtrip — letting youpc.org (or any future consumer) run
|
||||||
|
// the litigationplanner engine against the canonical UPC rule set.
|
||||||
|
//
|
||||||
|
// Slice C (m/paliad#124 §19). See docs/design-litigation-planner-2026-05-26.md
|
||||||
|
// §19 for the full design.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// DATABASE_URL=postgres://... go run ./cmd/gen-upc-snapshot \
|
||||||
|
// [-output ./pkg/litigationplanner/embedded/upc] \
|
||||||
|
// [-version 2026-05-26-1] \
|
||||||
|
// [-source-label paliad-dev-supabase]
|
||||||
|
//
|
||||||
|
// The generator applies migrations against DATABASE_URL before
|
||||||
|
// SELECTing (so the snapshot always matches schema HEAD). Idempotent —
|
||||||
|
// running twice with the same DB state produces the same JSON.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/paliad/internal/db"
|
||||||
|
"mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultOutput = "./pkg/litigationplanner/embedded/upc"
|
||||||
|
defaultSourceLabel = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
// Meta is the version block written to meta.json. The embedded sub-
|
||||||
|
// package re-defines this type so consumers can decode it without
|
||||||
|
// importing the cmd; the cmd holds the canonical write shape.
|
||||||
|
type Meta struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
GeneratedAt time.Time `json:"generated_at"`
|
||||||
|
PaliadCommit string `json:"paliad_commit,omitempty"`
|
||||||
|
SourceDBLabel string `json:"source_db_label,omitempty"`
|
||||||
|
RuleCount int `json:"rule_count"`
|
||||||
|
ProceedingCount int `json:"proceeding_count"`
|
||||||
|
TriggerEventCount int `json:"trigger_event_count"`
|
||||||
|
HolidayCount int `json:"holiday_count"`
|
||||||
|
CourtCount int `json:"court_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmbeddedHoliday is the holiday row shape the embedded snapshot
|
||||||
|
// stores. JSON tags mirror paliad.holidays so the generator's SELECT
|
||||||
|
// scans onto it directly + the embedded HolidayCalendar reads the
|
||||||
|
// same tag.
|
||||||
|
type EmbeddedHoliday struct {
|
||||||
|
Date string `db:"date_iso" json:"date"`
|
||||||
|
Name string `db:"name" json:"name"`
|
||||||
|
Country *string `db:"country" json:"country,omitempty"`
|
||||||
|
Regime *string `db:"regime" json:"regime,omitempty"`
|
||||||
|
State *string `db:"state" json:"state,omitempty"`
|
||||||
|
HolidayType string `db:"holiday_type" json:"holiday_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmbeddedCourt is the court row shape the embedded snapshot stores.
|
||||||
|
type EmbeddedCourt struct {
|
||||||
|
ID string `db:"id" json:"id"`
|
||||||
|
Code string `db:"code" json:"code"`
|
||||||
|
NameDE string `db:"name_de" json:"name_de"`
|
||||||
|
NameEN string `db:"name_en" json:"name_en"`
|
||||||
|
Country string `db:"country" json:"country"`
|
||||||
|
Regime *string `db:"regime" json:"regime,omitempty"`
|
||||||
|
CourtType string `db:"court_type" json:"court_type"`
|
||||||
|
ParentID *string `db:"parent_id" json:"parent_id,omitempty"`
|
||||||
|
SortOrder int `db:"sort_order" json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
output := flag.String("output", defaultOutput, "directory to write JSON files into")
|
||||||
|
version := flag.String("version", "", "explicit snapshot version (auto-derived if empty)")
|
||||||
|
sourceLabel := flag.String("source-label", defaultSourceLabel, "label for source_db in meta.json")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
url := os.Getenv("DATABASE_URL")
|
||||||
|
if url == "" {
|
||||||
|
log.Fatal("DATABASE_URL must be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.ApplyMigrations(url); err != nil {
|
||||||
|
log.Fatalf("apply migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pool, err := sqlx.Connect("postgres", url)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := run(ctx, pool, *output, *version, *sourceLabel); err != nil {
|
||||||
|
log.Fatalf("snapshot: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(ctx context.Context, pool *sqlx.DB, output, version, sourceLabel string) error {
|
||||||
|
if err := os.MkdirAll(output, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("mkdir output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Proceeding types — UPC + active only. The unified upc.apl row
|
||||||
|
// from B1 mig 134 is included; the 3 archived old appeal codes
|
||||||
|
// (is_active=false) are filtered out by the WHERE.
|
||||||
|
var procs []litigationplanner.ProceedingType
|
||||||
|
if err := pool.SelectContext(ctx, &procs, `
|
||||||
|
SELECT id, code, name, name_en, description, jurisdiction,
|
||||||
|
category, default_color, sort_order, is_active,
|
||||||
|
trigger_event_label_de, trigger_event_label_en,
|
||||||
|
appeal_target
|
||||||
|
FROM paliad.proceeding_types
|
||||||
|
WHERE jurisdiction = 'UPC' AND is_active = true
|
||||||
|
ORDER BY sort_order, id`); err != nil {
|
||||||
|
return fmt.Errorf("select proceeding_types: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(procs) == 0 {
|
||||||
|
return fmt.Errorf("no active UPC proceeding_types — refusing to write empty snapshot")
|
||||||
|
}
|
||||||
|
|
||||||
|
procIDs := make([]int, 0, len(procs))
|
||||||
|
for _, p := range procs {
|
||||||
|
procIDs = append(procIDs, p.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Deadline rules — published + active rules for those proceedings.
|
||||||
|
const ruleCols = `id, proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||||
|
description, primary_party, event_type, duration_value,
|
||||||
|
duration_unit, timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||||
|
alt_duration_value, alt_duration_unit, alt_rule_code,
|
||||||
|
anchor_alt, concept_id, legal_source, is_spawn, spawn_label, is_active,
|
||||||
|
created_at, updated_at,
|
||||||
|
trigger_event_id, spawn_proceeding_type_id, combine_op, condition_expr,
|
||||||
|
priority, is_court_set, lifecycle_state, draft_of, published_at,
|
||||||
|
choices_offered, applies_to_target`
|
||||||
|
|
||||||
|
q, args, err := sqlx.In(`
|
||||||
|
SELECT `+ruleCols+`
|
||||||
|
FROM paliad.deadline_rules
|
||||||
|
WHERE proceeding_type_id IN (?)
|
||||||
|
AND is_active = true
|
||||||
|
AND lifecycle_state = 'published'
|
||||||
|
ORDER BY proceeding_type_id, sequence_order`, procIDs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build rules IN: %w", err)
|
||||||
|
}
|
||||||
|
q = pool.Rebind(q)
|
||||||
|
var rules []litigationplanner.Rule
|
||||||
|
if err := pool.SelectContext(ctx, &rules, q, args...); err != nil {
|
||||||
|
return fmt.Errorf("select rules: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Trigger events referenced by any UPC rule's trigger_event_id.
|
||||||
|
triggerIDSet := make(map[int64]struct{})
|
||||||
|
for _, r := range rules {
|
||||||
|
if r.TriggerEventID != nil {
|
||||||
|
triggerIDSet[*r.TriggerEventID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var triggers []litigationplanner.TriggerEvent
|
||||||
|
if len(triggerIDSet) > 0 {
|
||||||
|
triggerIDs := make([]int64, 0, len(triggerIDSet))
|
||||||
|
for id := range triggerIDSet {
|
||||||
|
triggerIDs = append(triggerIDs, id)
|
||||||
|
}
|
||||||
|
q, args, err := sqlx.In(`
|
||||||
|
SELECT id, code, name, name_de, description, is_active, created_at
|
||||||
|
FROM paliad.trigger_events
|
||||||
|
WHERE id IN (?)
|
||||||
|
ORDER BY id`, triggerIDs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build triggers IN: %w", err)
|
||||||
|
}
|
||||||
|
q = pool.Rebind(q)
|
||||||
|
if err := pool.SelectContext(ctx, &triggers, q, args...); err != nil {
|
||||||
|
return fmt.Errorf("select trigger_events: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Holidays — DE national + UPC regime entries. The embedded
|
||||||
|
// calendar serves UPC computations so both axes matter.
|
||||||
|
var holidays []EmbeddedHoliday
|
||||||
|
if err := pool.SelectContext(ctx, &holidays, `
|
||||||
|
SELECT to_char(date, 'YYYY-MM-DD') AS date_iso,
|
||||||
|
name, country, regime, state, holiday_type
|
||||||
|
FROM paliad.holidays
|
||||||
|
WHERE country = 'DE' OR regime = 'UPC'
|
||||||
|
ORDER BY date, name`); err != nil {
|
||||||
|
return fmt.Errorf("select holidays: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Courts — UPC subset.
|
||||||
|
var courts []EmbeddedCourt
|
||||||
|
if err := pool.SelectContext(ctx, &courts, `
|
||||||
|
SELECT id, code, name_de, name_en, country, regime, court_type, parent_id, sort_order
|
||||||
|
FROM paliad.courts
|
||||||
|
WHERE is_active = true
|
||||||
|
AND (regime = 'UPC' OR court_type LIKE 'upc%')
|
||||||
|
ORDER BY sort_order, id`); err != nil {
|
||||||
|
return fmt.Errorf("select courts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Compose meta.
|
||||||
|
meta := Meta{
|
||||||
|
Version: resolveVersion(version, output),
|
||||||
|
GeneratedAt: time.Now().UTC().Truncate(time.Second),
|
||||||
|
PaliadCommit: gitCommitShort(),
|
||||||
|
SourceDBLabel: sourceLabel,
|
||||||
|
RuleCount: len(rules),
|
||||||
|
ProceedingCount: len(procs),
|
||||||
|
TriggerEventCount: len(triggers),
|
||||||
|
HolidayCount: len(holidays),
|
||||||
|
CourtCount: len(courts),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Write each file.
|
||||||
|
files := []struct {
|
||||||
|
name string
|
||||||
|
data any
|
||||||
|
}{
|
||||||
|
{"proceeding_types.json", procs},
|
||||||
|
{"rules.json", rules},
|
||||||
|
{"trigger_events.json", triggers},
|
||||||
|
{"holidays.json", holidays},
|
||||||
|
{"courts.json", courts},
|
||||||
|
{"meta.json", meta},
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
path := filepath.Join(output, f.name)
|
||||||
|
buf, err := json.MarshalIndent(f.data, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal %s: %w", f.name, err)
|
||||||
|
}
|
||||||
|
buf = append(buf, '\n')
|
||||||
|
if err := os.WriteFile(path, buf, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("snapshot written: version=%s rules=%d proceedings=%d triggers=%d holidays=%d courts=%d → %s",
|
||||||
|
meta.Version, meta.RuleCount, meta.ProceedingCount,
|
||||||
|
meta.TriggerEventCount, meta.HolidayCount, meta.CourtCount, output)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveVersion picks a date-stamped version slug, bumping the suffix
|
||||||
|
// past any pre-existing same-day version found in the existing
|
||||||
|
// meta.json. If the caller passed -version, that wins.
|
||||||
|
func resolveVersion(explicit, output string) string {
|
||||||
|
if explicit != "" {
|
||||||
|
return explicit
|
||||||
|
}
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
// Read prior meta to detect same-day collisions.
|
||||||
|
prior, err := os.ReadFile(filepath.Join(output, "meta.json"))
|
||||||
|
if err != nil {
|
||||||
|
return today + "-1"
|
||||||
|
}
|
||||||
|
var pm Meta
|
||||||
|
if err := json.Unmarshal(prior, &pm); err != nil {
|
||||||
|
return today + "-1"
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(pm.Version, today+"-") {
|
||||||
|
return today + "-1"
|
||||||
|
}
|
||||||
|
// Same day: bump the suffix.
|
||||||
|
suffix := pm.Version[len(today)+1:]
|
||||||
|
var n int
|
||||||
|
if _, err := fmt.Sscanf(suffix, "%d", &n); err != nil {
|
||||||
|
return today + "-1"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s-%d", today, n+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// gitCommitShort returns the short SHA of the paliad checkout. Best-
|
||||||
|
// effort — empty string when we're not in a git checkout.
|
||||||
|
func gitCommitShort() string {
|
||||||
|
out, err := exec.Command("git", "rev-parse", "--short", "HEAD").Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
@@ -1449,4 +1449,170 @@ No `AskUserQuestion` per inventor protocol; head escalates to m if material.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## §19 Slice C — embedded UPC snapshot + generator (2026-05-26)
|
||||||
|
|
||||||
|
Slice A landed the package, Slice B added the catalog API surface. Slice C lays the foundation for the youpc.org cross-repo integration: an in-package UPC subset of paliad's deadline corpus, embedded as JSON, that youpc.org can use to run the engine without any paliad DB access.
|
||||||
|
|
||||||
|
### §19.1 Goals
|
||||||
|
|
||||||
|
1. **Zero DB dependency for snapshot consumers.** youpc.org imports `pkg/litigationplanner/embedded/upc` and gets a working Catalog / HolidayCalendar / CourtRegistry without ever touching paliad's Postgres.
|
||||||
|
2. **Reproducible regeneration.** A generator binary (`cmd/gen-upc-snapshot`) reads paliad's live DB and produces the JSON. Idempotent — same DB state in, same JSON out.
|
||||||
|
3. **Versioned snapshots.** Each snapshot carries a `version` + `generated_at` so consumers can detect regeneration and decide whether to bump their go.mod.
|
||||||
|
4. **Stays in lockstep with paliad's engine.** The embedded data conforms to the same `Rule` / `ProceedingType` Go types the engine consumes — no schema drift, no parallel-vocab risk.
|
||||||
|
|
||||||
|
### §19.2 Embedding format
|
||||||
|
|
||||||
|
**Pick: `//go:embed` of JSON.**
|
||||||
|
|
||||||
|
Three candidates considered:
|
||||||
|
- A. **`//go:embed` of JSON files** — generator emits human-readable JSON; package reads at boot via `embed.FS`. Diff-friendly in git; youpc.org sees the bytes change in code review.
|
||||||
|
- B. **Generated Go const literals** — generator emits a `.go` file with the rule slice inlined. Type-safe at compile; harder to diff (big generated files); pollutes `git log -p` with mechanical changes.
|
||||||
|
- C. **External resource fetched at runtime** — youpc.org would HTTP-GET the snapshot from a paliad endpoint. Adds runtime coupling between the two services; defeats the "zero DB dependency" goal.
|
||||||
|
|
||||||
|
**(R) = A**. JSON is the wire shape paliad's API already serves; the package's `Rule` struct already has compatible `json:` tags from Slice A. The generated bytes survive `git diff` cleanly. youpc.org can also vendor the JSON via go-module if they want fully reproducible builds.
|
||||||
|
|
||||||
|
### §19.3 File layout
|
||||||
|
|
||||||
|
```
|
||||||
|
pkg/litigationplanner/embedded/upc/
|
||||||
|
embed.go ← //go:embed *.json + package metadata
|
||||||
|
snapshot.go ← SnapshotCatalog struct + Load() helper
|
||||||
|
snapshot_test.go ← unit tests against the embedded data
|
||||||
|
rules.json ← generator output: all UPC rules
|
||||||
|
proceeding_types.json ← generator output: all UPC proceeding types
|
||||||
|
trigger_events.json ← generator output: UPC-referenced trigger events
|
||||||
|
holidays.json ← generator output: DE + UPC regime holidays
|
||||||
|
courts.json ← generator output: UPC courts
|
||||||
|
meta.json ← generator output: {version, generated_at, paliad_commit, source_db_label}
|
||||||
|
|
||||||
|
cmd/gen-upc-snapshot/
|
||||||
|
main.go ← generator entry point
|
||||||
|
README.md ← operator runbook
|
||||||
|
```
|
||||||
|
|
||||||
|
`pkg/litigationplanner/embedded/upc` is the public consumer surface. youpc.org imports it as:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import upc "mgit.msbls.de/m/paliad/pkg/litigationplanner/embedded/upc"
|
||||||
|
|
||||||
|
cat, _ := upc.NewCatalog()
|
||||||
|
hc, _ := upc.NewHolidayCalendar()
|
||||||
|
cr, _ := upc.NewCourtRegistry()
|
||||||
|
|
||||||
|
timeline, err := lp.Calculate(ctx, "upc.inf.cfi", "2026-05-26", lp.CalcOptions{...}, cat, hc, cr)
|
||||||
|
```
|
||||||
|
|
||||||
|
### §19.4 Snapshot data shape
|
||||||
|
|
||||||
|
The five data files (`rules.json`, `proceeding_types.json`, `trigger_events.json`, `holidays.json`, `courts.json`) are each a top-level JSON array of the corresponding type. The package's `Rule` / `ProceedingType` / `TriggerEvent` structs deserialise directly (their `json:` tags align with paliad's wire shape).
|
||||||
|
|
||||||
|
`holidays.json` and `courts.json` use minimal structures defined in the embedded sub-package (the package's core API only requires `HolidayCalendar` / `CourtRegistry` interfaces — no struct contract).
|
||||||
|
|
||||||
|
`meta.json` carries the versioning block:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "2026-05-26-1",
|
||||||
|
"generated_at": "2026-05-26T15:01:00Z",
|
||||||
|
"paliad_commit": "932b177",
|
||||||
|
"source_db_label": "paliad-dev-supabase",
|
||||||
|
"rule_count": 81,
|
||||||
|
"proceeding_count": 9,
|
||||||
|
"trigger_event_count": 2,
|
||||||
|
"holiday_count": 142,
|
||||||
|
"court_count": 18
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`version` uses a date-stamped scheme (`YYYY-MM-DD-N` where N starts at 1 and increments for same-day regenerations) — simple, sortable, no merge conflicts on regen.
|
||||||
|
|
||||||
|
### §19.5 Generator
|
||||||
|
|
||||||
|
`cmd/gen-upc-snapshot/main.go` runs as:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
DATABASE_URL=postgres://... \
|
||||||
|
go run ./cmd/gen-upc-snapshot \
|
||||||
|
-output ./pkg/litigationplanner/embedded/upc
|
||||||
|
```
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Connect to `DATABASE_URL` (paliad's live DB).
|
||||||
|
2. Apply migrations first (`db.ApplyMigrations(url)`) — ensures the snapshot matches schema HEAD.
|
||||||
|
3. SELECT all `paliad.proceeding_types` WHERE `jurisdiction = 'UPC'` AND `is_active = true`. (After B1 the unified `upc.apl` is the only appeal proceeding — the 3 archived old codes are filtered out.)
|
||||||
|
4. SELECT all `paliad.deadline_rules` for those proceeding ids WHERE `lifecycle_state = 'published'` AND `is_active = true`.
|
||||||
|
5. SELECT `paliad.trigger_events` referenced by any rule's `trigger_event_id`.
|
||||||
|
6. SELECT `paliad.holidays` filtered to `country = 'DE' OR regime = 'UPC'` (the union UPC procedures need).
|
||||||
|
7. SELECT `paliad.courts` filtered to `regime = 'UPC' OR court_type LIKE 'upc%'` (UPC court hierarchy).
|
||||||
|
8. Write each result set to `<output>/<name>.json` (pretty-printed for diff-friendliness).
|
||||||
|
9. Compute meta — current paliad commit (via `git rev-parse --short HEAD`), timestamp, row counts.
|
||||||
|
10. Write `meta.json`.
|
||||||
|
|
||||||
|
**Versioning rule**: the generator never overwrites a meta.json with `version` equal to an existing one. If today's date is already used (suffix `-1`), the generator bumps to `-2`. This keeps regenerations within a day distinguishable. Operator can pass `-version <string>` to override.
|
||||||
|
|
||||||
|
### §19.6 Regeneration trigger
|
||||||
|
|
||||||
|
Manual. Three entry points:
|
||||||
|
|
||||||
|
- **`make snapshot-upc`** — Make target invokes the generator with `DATABASE_URL` from env. Documented in `cmd/gen-upc-snapshot/README.md`.
|
||||||
|
- **`go generate ./pkg/litigationplanner/embedded/upc`** — `//go:generate` directive on a stub in the package. Same effect; lets contributors discover the regen path from the package they're modifying.
|
||||||
|
- **Operator runs the command directly** — power-user path.
|
||||||
|
|
||||||
|
**No CI regeneration in v1.** The snapshot is operator-controlled. Future slice can add a nightly CI job that opens a PR with the regenerated snapshot if drift is detected (out of scope here).
|
||||||
|
|
||||||
|
### §19.7 SnapshotCatalog implementation
|
||||||
|
|
||||||
|
In `pkg/litigationplanner/embedded/upc/snapshot.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type SnapshotCatalog struct {
|
||||||
|
proceedings []litigationplanner.ProceedingType
|
||||||
|
rules []litigationplanner.Rule
|
||||||
|
triggerEvents map[int64]litigationplanner.TriggerEvent
|
||||||
|
rulesByProc map[int][]litigationplanner.Rule // for LoadProceeding
|
||||||
|
rulesByID map[uuid.UUID]litigationplanner.Rule
|
||||||
|
procByID map[int]litigationplanner.ProceedingType
|
||||||
|
procByCode map[string]litigationplanner.ProceedingType
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCatalog() (*SnapshotCatalog, error) // parses embedded JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
All 7 Catalog interface methods (`LoadProceeding`, `LoadProceedingByID`, `LoadRuleByID`, `LoadRuleByCode`, `LoadRulesByTriggerEvent`, `LoadTriggerEventsByIDs`, `LookupEvents`) implemented against the in-memory maps. Lookup methods are O(1) on the indexed maps; `LookupEvents` does a linear scan of `rules` (the UPC subset is < 100 rows; no index needed).
|
||||||
|
|
||||||
|
`ProjectHint` is ignored on the snapshot side (youpc.org has no projects). `applies_to_target` filter for B1 works identically — the rules carry the same array.
|
||||||
|
|
||||||
|
`HolidayCalendar` impl mirrors paliad's `HolidayService` but reads from the embedded holiday slice instead of paliad.holidays. Same `AdjustForNonWorkingDaysWithReason` semantics.
|
||||||
|
|
||||||
|
`CourtRegistry` impl mirrors `CourtService.CountryRegime`. UPC courts only.
|
||||||
|
|
||||||
|
### §19.8 Tests
|
||||||
|
|
||||||
|
`snapshot_test.go` exercises:
|
||||||
|
- Snapshot loads without error
|
||||||
|
- `meta.json` parses + has non-zero counts
|
||||||
|
- `LoadProceeding(ctx, "upc.inf.cfi", ProjectHint{})` returns the expected proceeding + > 0 rules
|
||||||
|
- `LookupEvents(ctx, EventLookupAxes{Jurisdiction:"UPC"}, EventLookupDepthAllFollowing)` returns all rules
|
||||||
|
- A golden compute: `Calculate(ctx, "upc.inf.cfi", "2026-01-15", CalcOptions{}, cat, hc, cr)` produces a non-empty timeline with a known root rule (Klageerhebung)
|
||||||
|
|
||||||
|
All tests run without a DB (zero `os.Getenv("TEST_DATABASE_URL")` checks).
|
||||||
|
|
||||||
|
### §19.9 Acceptance criteria
|
||||||
|
|
||||||
|
1. `cmd/gen-upc-snapshot` exists + builds + runs against the live paliad DB.
|
||||||
|
2. `pkg/litigationplanner/embedded/upc/*.json` checked in with the first generated snapshot.
|
||||||
|
3. `embedded/upc.NewCatalog()` (+ `NewHolidayCalendar` + `NewCourtRegistry`) return ready-to-use implementations of the package interfaces.
|
||||||
|
4. Unit tests in `embedded/upc` pass without `TEST_DATABASE_URL` (no DB roundtrip).
|
||||||
|
5. `make snapshot-upc` regenerates the snapshot.
|
||||||
|
6. `go build ./...` + `go test ./...` all green.
|
||||||
|
|
||||||
|
### §19.10 Out of scope (deferred to follow-up)
|
||||||
|
|
||||||
|
- Snapshot signing / integrity attestation. v1 is plain JSON; future slice can ship a `meta.sig` next to `meta.json` for tamper detection.
|
||||||
|
- DE/EPA/DPMA snapshots. v1 only ships the UPC subset (matches youpc.org's scope). Future jurisdictions add as sibling packages: `embedded/de`, `embedded/epa`, etc.
|
||||||
|
- CI regeneration cron. Operator-driven only in v1.
|
||||||
|
- Snapshot diff tooling. v1 relies on `git diff` of the JSON files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
*End of design doc.*
|
*End of design doc.*
|
||||||
|
|||||||
66
pkg/litigationplanner/embedded/upc/courts.go
Normal file
66
pkg/litigationplanner/embedded/upc/courts.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package upc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SnapshotCourt is the embedded court row shape. Mirrors paliad.courts.
|
||||||
|
type SnapshotCourt struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
NameDE string `json:"name_de"`
|
||||||
|
NameEN string `json:"name_en"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
Regime *string `json:"regime,omitempty"`
|
||||||
|
CourtType string `json:"court_type"`
|
||||||
|
ParentID *string `json:"parent_id,omitempty"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SnapshotCourtRegistry serves CourtRegistry against the embedded
|
||||||
|
// court slice. UPC subset only (DE / EPA / DPMA courts are NOT in
|
||||||
|
// the snapshot — youpc.org has no need for them, and a request for
|
||||||
|
// a non-UPC court id falls through to default country/regime per the
|
||||||
|
// CountryRegime contract).
|
||||||
|
type SnapshotCourtRegistry struct {
|
||||||
|
byID map[string]SnapshotCourt
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCourtRegistry parses the embedded courts.json and returns a
|
||||||
|
// ready-to-use registry.
|
||||||
|
func NewCourtRegistry() (*SnapshotCourtRegistry, error) {
|
||||||
|
var courts []SnapshotCourt
|
||||||
|
if err := readJSON("courts.json", &courts); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r := &SnapshotCourtRegistry{byID: make(map[string]SnapshotCourt, len(courts))}
|
||||||
|
for _, c := range courts {
|
||||||
|
r.byID[c.ID] = c
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountryRegime resolves a court ID to its (country, regime) tuple.
|
||||||
|
// Empty courtID falls back to (defaultCountry, defaultRegime) per the
|
||||||
|
// interface contract. ErrUnknownCourt-equivalent (a plain error here)
|
||||||
|
// when courtID is non-empty but absent from the snapshot.
|
||||||
|
func (r *SnapshotCourtRegistry) CountryRegime(courtID, defaultCountry, defaultRegime string) (country, regime string, err error) {
|
||||||
|
if courtID == "" {
|
||||||
|
return defaultCountry, defaultRegime, nil
|
||||||
|
}
|
||||||
|
c, ok := r.byID[courtID]
|
||||||
|
if !ok {
|
||||||
|
return "", "", fmt.Errorf("upc snapshot: unknown court id %q", courtID)
|
||||||
|
}
|
||||||
|
reg := ""
|
||||||
|
if c.Regime != nil {
|
||||||
|
reg = *c.Regime
|
||||||
|
}
|
||||||
|
return c.Country, reg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time assertion that SnapshotCourtRegistry satisfies
|
||||||
|
// lp.CourtRegistry.
|
||||||
|
var _ lp.CourtRegistry = (*SnapshotCourtRegistry)(nil)
|
||||||
22
pkg/litigationplanner/embedded/upc/courts.json
Normal file
22
pkg/litigationplanner/embedded/upc/courts.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "upc-ld-munich",
|
||||||
|
"code": "upc-ld-munich",
|
||||||
|
"name_de": "UPC Lokalkammer München",
|
||||||
|
"name_en": "UPC Local Division Munich",
|
||||||
|
"country": "DE",
|
||||||
|
"regime": "UPC",
|
||||||
|
"court_type": "upc-ld",
|
||||||
|
"sort_order": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "upc-coa",
|
||||||
|
"code": "upc-coa",
|
||||||
|
"name_de": "UPC Berufungsgericht",
|
||||||
|
"name_en": "UPC Court of Appeal",
|
||||||
|
"country": "LU",
|
||||||
|
"regime": "UPC",
|
||||||
|
"court_type": "upc-coa",
|
||||||
|
"sort_order": 100
|
||||||
|
}
|
||||||
|
]
|
||||||
80
pkg/litigationplanner/embedded/upc/embed.go
Normal file
80
pkg/litigationplanner/embedded/upc/embed.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// Package upc provides an embedded, DB-free implementation of the
|
||||||
|
// litigationplanner Catalog / HolidayCalendar / CourtRegistry
|
||||||
|
// interfaces, populated from a JSON snapshot of paliad's UPC rule
|
||||||
|
// corpus.
|
||||||
|
//
|
||||||
|
// Slice C of the litigation-planner extraction (m/paliad#124 §19).
|
||||||
|
//
|
||||||
|
// Consumers (today: youpc.org; future: any third-party UPC tool) wire
|
||||||
|
// the engine like this:
|
||||||
|
//
|
||||||
|
// import (
|
||||||
|
// lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||||
|
// upc "mgit.msbls.de/m/paliad/pkg/litigationplanner/embedded/upc"
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// cat, _ := upc.NewCatalog()
|
||||||
|
// hc, _ := upc.NewHolidayCalendar()
|
||||||
|
// cr, _ := upc.NewCourtRegistry()
|
||||||
|
//
|
||||||
|
// timeline, err := lp.Calculate(ctx, "upc.inf.cfi", "2026-05-26",
|
||||||
|
// lp.CalcOptions{}, cat, hc, cr)
|
||||||
|
//
|
||||||
|
// Regenerating the snapshot: see cmd/gen-upc-snapshot/README.md.
|
||||||
|
//
|
||||||
|
//go:generate sh -c "echo 'snapshot is regenerated via the gen-upc-snapshot binary — see cmd/gen-upc-snapshot/README.md'"
|
||||||
|
package upc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rawFS holds the snapshot JSON files. The data files are produced by
|
||||||
|
// cmd/gen-upc-snapshot from a paliad live DB.
|
||||||
|
//
|
||||||
|
//go:embed *.json
|
||||||
|
var rawFS embed.FS
|
||||||
|
|
||||||
|
// Meta is the version block from meta.json.
|
||||||
|
type Meta struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
GeneratedAt time.Time `json:"generated_at"`
|
||||||
|
PaliadCommit string `json:"paliad_commit,omitempty"`
|
||||||
|
SourceDBLabel string `json:"source_db_label,omitempty"`
|
||||||
|
RuleCount int `json:"rule_count"`
|
||||||
|
ProceedingCount int `json:"proceeding_count"`
|
||||||
|
TriggerEventCount int `json:"trigger_event_count"`
|
||||||
|
HolidayCount int `json:"holiday_count"`
|
||||||
|
CourtCount int `json:"court_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadMeta parses meta.json from the embedded snapshot. Returns an
|
||||||
|
// error when the snapshot hasn't been generated yet (meta.json
|
||||||
|
// missing or empty).
|
||||||
|
func LoadMeta() (Meta, error) {
|
||||||
|
var m Meta
|
||||||
|
buf, err := rawFS.ReadFile("meta.json")
|
||||||
|
if err != nil {
|
||||||
|
return Meta{}, fmt.Errorf("read meta.json: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(buf, &m); err != nil {
|
||||||
|
return Meta{}, fmt.Errorf("decode meta.json: %w", err)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readJSON is a tiny helper that decodes one of the embedded files
|
||||||
|
// into a destination value.
|
||||||
|
func readJSON(name string, dst any) error {
|
||||||
|
buf, err := rawFS.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(buf, dst); err != nil {
|
||||||
|
return fmt.Errorf("decode %s: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
216
pkg/litigationplanner/embedded/upc/holidays.go
Normal file
216
pkg/litigationplanner/embedded/upc/holidays.go
Normal 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)
|
||||||
32
pkg/litigationplanner/embedded/upc/holidays.json
Normal file
32
pkg/litigationplanner/embedded/upc/holidays.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"date": "2026-01-01",
|
||||||
|
"name": "Neujahr",
|
||||||
|
"country": "DE",
|
||||||
|
"holiday_type": "closure"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-05-01",
|
||||||
|
"name": "Tag der Arbeit",
|
||||||
|
"country": "DE",
|
||||||
|
"holiday_type": "closure"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"name": "UPC Sommerpause",
|
||||||
|
"regime": "UPC",
|
||||||
|
"holiday_type": "vacation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-25",
|
||||||
|
"name": "UPC Sommerpause",
|
||||||
|
"regime": "UPC",
|
||||||
|
"holiday_type": "vacation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-08-26",
|
||||||
|
"name": "UPC Sommerpause",
|
||||||
|
"regime": "UPC",
|
||||||
|
"holiday_type": "vacation"
|
||||||
|
}
|
||||||
|
]
|
||||||
11
pkg/litigationplanner/embedded/upc/meta.json
Normal file
11
pkg/litigationplanner/embedded/upc/meta.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "2026-05-26-1-placeholder",
|
||||||
|
"generated_at": "2026-05-26T15:00:00Z",
|
||||||
|
"paliad_commit": "",
|
||||||
|
"source_db_label": "placeholder — operator must run `make snapshot-upc` against prod once mig 134/135 are applied",
|
||||||
|
"rule_count": 2,
|
||||||
|
"proceeding_count": 2,
|
||||||
|
"trigger_event_count": 0,
|
||||||
|
"holiday_count": 5,
|
||||||
|
"court_count": 2
|
||||||
|
}
|
||||||
32
pkg/litigationplanner/embedded/upc/proceeding_types.json
Normal file
32
pkg/litigationplanner/embedded/upc/proceeding_types.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"code": "upc.inf.cfi",
|
||||||
|
"name": "Verletzungsverfahren",
|
||||||
|
"name_en": "Infringement Action",
|
||||||
|
"description": "UPC infringement proceedings at first instance.",
|
||||||
|
"jurisdiction": "UPC",
|
||||||
|
"category": "fristenrechner",
|
||||||
|
"default_color": "#3b82f6",
|
||||||
|
"sort_order": 10,
|
||||||
|
"is_active": true,
|
||||||
|
"trigger_event_label_de": null,
|
||||||
|
"trigger_event_label_en": null,
|
||||||
|
"appeal_target": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"code": "upc.rev.cfi",
|
||||||
|
"name": "Nichtigkeitsverfahren",
|
||||||
|
"name_en": "Revocation Action",
|
||||||
|
"description": "UPC revocation proceedings at first instance.",
|
||||||
|
"jurisdiction": "UPC",
|
||||||
|
"category": "fristenrechner",
|
||||||
|
"default_color": "#f59e0b",
|
||||||
|
"sort_order": 20,
|
||||||
|
"is_active": true,
|
||||||
|
"trigger_event_label_de": null,
|
||||||
|
"trigger_event_label_en": null,
|
||||||
|
"appeal_target": null
|
||||||
|
}
|
||||||
|
]
|
||||||
43
pkg/litigationplanner/embedded/upc/rules.json
Normal file
43
pkg/litigationplanner/embedded/upc/rules.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"proceeding_type_id": 8,
|
||||||
|
"submission_code": "upc.inf.cfi.soc",
|
||||||
|
"name": "Klageerhebung",
|
||||||
|
"name_en": "Statement of Claim",
|
||||||
|
"duration_value": 0,
|
||||||
|
"duration_unit": "months",
|
||||||
|
"sequence_order": 1,
|
||||||
|
"is_spawn": false,
|
||||||
|
"is_active": true,
|
||||||
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2026-01-01T00:00:00Z",
|
||||||
|
"priority": "mandatory",
|
||||||
|
"is_court_set": false,
|
||||||
|
"is_bilateral": false,
|
||||||
|
"lifecycle_state": "published"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "22222222-2222-2222-2222-222222222222",
|
||||||
|
"proceeding_type_id": 8,
|
||||||
|
"parent_id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"submission_code": "upc.inf.cfi.sod",
|
||||||
|
"name": "Klageerwiderung",
|
||||||
|
"name_en": "Statement of Defence",
|
||||||
|
"primary_party": "defendant",
|
||||||
|
"duration_value": 3,
|
||||||
|
"duration_unit": "months",
|
||||||
|
"timing": "after",
|
||||||
|
"rule_code": "UPC.RoP.23.1",
|
||||||
|
"legal_source": "UPC.RoP.23.1",
|
||||||
|
"sequence_order": 2,
|
||||||
|
"is_spawn": false,
|
||||||
|
"is_active": true,
|
||||||
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2026-01-01T00:00:00Z",
|
||||||
|
"priority": "mandatory",
|
||||||
|
"is_court_set": false,
|
||||||
|
"is_bilateral": false,
|
||||||
|
"lifecycle_state": "published"
|
||||||
|
}
|
||||||
|
]
|
||||||
301
pkg/litigationplanner/embedded/upc/snapshot.go
Normal file
301
pkg/litigationplanner/embedded/upc/snapshot.go
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
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")
|
||||||
215
pkg/litigationplanner/embedded/upc/snapshot_test.go
Normal file
215
pkg/litigationplanner/embedded/upc/snapshot_test.go
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
package upc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSnapshotMeta loads + parses meta.json and asserts the version
|
||||||
|
// + non-zero counts. Until the operator regenerates the snapshot the
|
||||||
|
// placeholder shipped with Slice C must still parse cleanly.
|
||||||
|
func TestSnapshotMeta(t *testing.T) {
|
||||||
|
meta, err := LoadMeta()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadMeta: %v", err)
|
||||||
|
}
|
||||||
|
if meta.Version == "" {
|
||||||
|
t.Error("meta.Version is empty")
|
||||||
|
}
|
||||||
|
if meta.ProceedingCount <= 0 {
|
||||||
|
t.Errorf("meta.ProceedingCount = %d, want > 0", meta.ProceedingCount)
|
||||||
|
}
|
||||||
|
if meta.RuleCount <= 0 {
|
||||||
|
t.Errorf("meta.RuleCount = %d, want > 0", meta.RuleCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSnapshotCatalog smoke-tests the embedded catalog's lookups
|
||||||
|
// against the shipped placeholder. After operator regeneration the
|
||||||
|
// asserts on per-row content still hold because they pin the wire
|
||||||
|
// shape (proceedingType.Code, rule resolution by code, lookup-events
|
||||||
|
// jurisdiction filter).
|
||||||
|
func TestSnapshotCatalog(t *testing.T) {
|
||||||
|
cat, err := NewCatalog()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCatalog: %v", err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("LoadProceeding upc.inf.cfi", func(t *testing.T) {
|
||||||
|
pt, rules, err := cat.LoadProceeding(ctx, "upc.inf.cfi", lp.ProjectHint{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadProceeding: %v", err)
|
||||||
|
}
|
||||||
|
if pt.Code != "upc.inf.cfi" {
|
||||||
|
t.Errorf("pt.Code = %q, want upc.inf.cfi", pt.Code)
|
||||||
|
}
|
||||||
|
if pt.Jurisdiction == nil || *pt.Jurisdiction != "UPC" {
|
||||||
|
t.Errorf("pt.Jurisdiction = %v, want UPC", pt.Jurisdiction)
|
||||||
|
}
|
||||||
|
if len(rules) == 0 {
|
||||||
|
t.Error("LoadProceeding returned zero rules — snapshot empty?")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("LoadProceeding unknown code returns ErrUnknownProceedingType", func(t *testing.T) {
|
||||||
|
_, _, err := cat.LoadProceeding(ctx, "no.such.code", lp.ProjectHint{})
|
||||||
|
if err != lp.ErrUnknownProceedingType {
|
||||||
|
t.Errorf("got %v, want ErrUnknownProceedingType", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("LookupEvents UPC all-following returns the whole UPC corpus", func(t *testing.T) {
|
||||||
|
matches, err := cat.LookupEvents(ctx, lp.EventLookupAxes{
|
||||||
|
Jurisdiction: "UPC",
|
||||||
|
}, lp.EventLookupDepthAllFollowing)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupEvents: %v", err)
|
||||||
|
}
|
||||||
|
if len(matches) == 0 {
|
||||||
|
t.Fatal("expected non-empty UPC corpus")
|
||||||
|
}
|
||||||
|
for _, m := range matches {
|
||||||
|
if m.ProceedingType.Jurisdiction == nil || *m.ProceedingType.Jurisdiction != "UPC" {
|
||||||
|
t.Errorf("non-UPC row leaked: %v", m.ProceedingType.Code)
|
||||||
|
}
|
||||||
|
if m.DepthFromAnchor < 1 {
|
||||||
|
t.Errorf("depth = %d, want >= 1", m.DepthFromAnchor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("LookupEvents party=defendant scopes anchors", func(t *testing.T) {
|
||||||
|
matches, err := cat.LookupEvents(ctx, lp.EventLookupAxes{
|
||||||
|
Jurisdiction: "UPC",
|
||||||
|
Party: "defendant",
|
||||||
|
}, lp.EventLookupDepthNext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupEvents: %v", err)
|
||||||
|
}
|
||||||
|
// Anchor rows (depth=1) must all be defendant.
|
||||||
|
anyDefendant := false
|
||||||
|
for _, m := range matches {
|
||||||
|
if m.DepthFromAnchor != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.Rule.PrimaryParty == nil || *m.Rule.PrimaryParty != "defendant" {
|
||||||
|
t.Errorf("anchor row %s is not defendant: %v", m.Rule.Name, m.Rule.PrimaryParty)
|
||||||
|
}
|
||||||
|
anyDefendant = true
|
||||||
|
}
|
||||||
|
if !anyDefendant {
|
||||||
|
t.Log("no defendant rules in the placeholder corpus — operator should regenerate the snapshot")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSnapshotEngineCompute runs the litigationplanner engine against
|
||||||
|
// the embedded snapshot end-to-end. Ensures the wiring between the
|
||||||
|
// snapshot Catalog / HolidayCalendar / CourtRegistry + the engine
|
||||||
|
// produces a non-empty timeline.
|
||||||
|
func TestSnapshotEngineCompute(t *testing.T) {
|
||||||
|
cat, err := NewCatalog()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCatalog: %v", err)
|
||||||
|
}
|
||||||
|
hc, err := NewHolidayCalendar()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHolidayCalendar: %v", err)
|
||||||
|
}
|
||||||
|
cr, err := NewCourtRegistry()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCourtRegistry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
timeline, err := lp.Calculate(ctx, "upc.inf.cfi", "2026-01-15", lp.CalcOptions{}, cat, hc, cr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Calculate: %v", err)
|
||||||
|
}
|
||||||
|
if timeline == nil {
|
||||||
|
t.Fatal("Calculate returned nil timeline")
|
||||||
|
}
|
||||||
|
if timeline.ProceedingType != "upc.inf.cfi" {
|
||||||
|
t.Errorf("timeline.ProceedingType = %q, want upc.inf.cfi", timeline.ProceedingType)
|
||||||
|
}
|
||||||
|
if len(timeline.Deadlines) == 0 {
|
||||||
|
t.Error("timeline has zero deadlines — snapshot empty?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSnapshotHolidayCalendar smoke-tests the embedded calendar.
|
||||||
|
// Pins core semantics: weekends are non-working; holidays at
|
||||||
|
// matching country/regime are non-working; mismatches don't fire.
|
||||||
|
func TestSnapshotHolidayCalendar(t *testing.T) {
|
||||||
|
hc, err := NewHolidayCalendar()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHolidayCalendar: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2026-01-03 is a Saturday — weekend, non-working regardless of
|
||||||
|
// country/regime.
|
||||||
|
sat := time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC)
|
||||||
|
if !hc.IsNonWorkingDay(sat, "DE", "UPC") {
|
||||||
|
t.Error("Saturday should be non-working")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2026-01-01 is Neujahr (DE closure) — non-working when country=DE.
|
||||||
|
newYear := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
if !hc.IsNonWorkingDay(newYear, "DE", "UPC") {
|
||||||
|
t.Error("Neujahr should be non-working for DE")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2026-01-05 is a Monday — working (not in holidays, not weekend).
|
||||||
|
mon := time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC)
|
||||||
|
if hc.IsNonWorkingDay(mon, "DE", "UPC") {
|
||||||
|
t.Error("Monday 2026-01-05 should be working")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdjustForNonWorkingDays from a Saturday should land on Monday.
|
||||||
|
adj, _, was := hc.AdjustForNonWorkingDays(sat, "DE", "UPC")
|
||||||
|
if !was {
|
||||||
|
t.Error("expected adjustment for Saturday")
|
||||||
|
}
|
||||||
|
if adj.Weekday() != time.Monday {
|
||||||
|
t.Errorf("adjusted weekday = %v, want Monday", adj.Weekday())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSnapshotCourtRegistry pins (country, regime) resolution.
|
||||||
|
func TestSnapshotCourtRegistry(t *testing.T) {
|
||||||
|
cr, err := NewCourtRegistry()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCourtRegistry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("empty courtID falls back to defaults", func(t *testing.T) {
|
||||||
|
c, r, err := cr.CountryRegime("", "DE", "UPC")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CountryRegime: %v", err)
|
||||||
|
}
|
||||||
|
if c != "DE" || r != "UPC" {
|
||||||
|
t.Errorf("got (%q, %q), want (DE, UPC)", c, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("known UPC court resolves", func(t *testing.T) {
|
||||||
|
c, r, err := cr.CountryRegime("upc-ld-munich", "DE", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CountryRegime: %v", err)
|
||||||
|
}
|
||||||
|
if c != "DE" || r != "UPC" {
|
||||||
|
t.Errorf("got (%q, %q), want (DE, UPC)", c, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown court returns error", func(t *testing.T) {
|
||||||
|
_, _, err := cr.CountryRegime("not-a-court", "DE", "UPC")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for unknown court")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
1
pkg/litigationplanner/embedded/upc/trigger_events.json
Normal file
1
pkg/litigationplanner/embedded/upc/trigger_events.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
Reference in New Issue
Block a user