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/
|
||||
# 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:
|
||||
@echo "Paliad — developer targets"
|
||||
@@ -33,6 +33,8 @@ help:
|
||||
@echo " test Short test pass — covers gate tier"
|
||||
@echo " test-go Full Go suite with race detector"
|
||||
@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 "Set TEST_DATABASE_URL to enable live-DB tests. Example:"
|
||||
@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
|
||||
@rm internal/db/testdata/prod-snapshot.sql.tmp
|
||||
@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.*
|
||||
|
||||
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