diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index aaf367d..462e941 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,11 +12,11 @@ jobs: steps: - uses: actions/checkout@v2 - name: Set up Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v5 with: - go-version: 1.22 + go-version: '1.25' - name: Install golangci-lint - run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.60.2 + run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8 - name: Run golangci-lint run: golangci-lint run diff --git a/Dockerfile b/Dockerfile index 3887cc4..d59c6ef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.22-alpine AS builder +FROM golang:1.25-alpine AS builder # --- config that differs locally vs OpenShift --- ARG CERT_PATH=./gridCertificate.p12 # default for local builds @@ -77,4 +77,4 @@ RUN mkdir -p . && \ USER 1001 EXPOSE 8088 -CMD ["./AliceTraINT"] \ No newline at end of file +CMD ["./AliceTraINT"] diff --git a/cmd/aod_batcher/main.go b/cmd/aod_batcher/main.go index bb24817..3f4aa90 100644 --- a/cmd/aod_batcher/main.go +++ b/cmd/aod_batcher/main.go @@ -9,21 +9,30 @@ import ( "math" "os" "path/filepath" + "regexp" "sort" "time" "github.com/mytkom/AliceTraINT/internal/jalien" + "github.com/mytkom/AliceTraINT/internal/monalisa" ) -type config struct { - path string - runs int - filesPerRun int - maxRunsPerBatch int - maxFilesPerBatch int - minSizeMB float64 - outputDir string +const MONALISA_DEFAULT_BASE_URL = "https://alimonitor.cern.ch" + +func getDataPath(dataTag, passName string, run uint64) string { + yearSuffix := dataTag[3:5] + return fmt.Sprintf("/alice/data/20%s/%s/%d/%s", yearSuffix, dataTag, run, passName) +} +type config struct { + path string + runs int + filesPerRun int + filesPerRunData int + maxFilesPerBatch int + minSizeMB float64 + outputDir string + monalisaBaseUrl string jalienHost string jalienPort string clientCert string @@ -36,7 +45,7 @@ type metadata struct { Path string `json:"path"` Runs int `json:"runs"` FilesPerRun int `json:"files_per_run"` - MaxRunsPerBatch int `json:"max_runs_per_batch"` + FilesPerRunData int `json:"files_per_run_data"` MaxFilesPerBatch int `json:"max_files_per_batch"` MinSizeMB float64 `json:"min_size_mb"` Timestamp string `json:"timestamp"` @@ -59,12 +68,13 @@ func parseFlags() (*config, error) { flag.StringVar(&cfg.path, "path", "", "JAliEn path under which to search for AOD files (e.g. /alice/sim/2024/LHC24f3)") flag.IntVar(&cfg.runs, "runs", 0, "Total number of runs to select") flag.IntVar(&cfg.filesPerRun, "files-per-run", 0, "Number of AOD files to select for each chosen run") - flag.IntVar(&cfg.maxRunsPerBatch, "max-runs-per-batch", -1, "Maximum number of runs to include in a single batch") + flag.IntVar(&cfg.filesPerRunData, "files-per-run-data", 0, "Number of AOD files to select for each chosen run's experimental data (typically 3-4x bigger files than for MC)") flag.IntVar(&cfg.maxFilesPerBatch, "max-files-per-batch", -1, "Maximum number of AOD files to include in a single batch") flag.Float64Var(&cfg.minSizeMB, "min-size-mb", 0, "Optional minimal AOD file size in megabytes; files smaller than this are excluded") flag.StringVar(&cfg.outputDir, "output-dir", "", "Directory where batch .txt files will be written") flag.UintVar(&cfg.jalienTimeoutSeconds, "jalien-timeout-seconds", 600, "JAliEn timeout in seconds") // JAliEn connectivity; defaults from environment if flags are not provided. + flag.StringVar(&cfg.monalisaBaseUrl, "monalisa-base-url", MONALISA_DEFAULT_BASE_URL, "Monalisa base url used for querying anchor prod tag") flag.StringVar(&cfg.jalienHost, "jalien-host", os.Getenv("JALIEN_HOST"), "JAliEn host (default from $JALIEN_HOST or internal default)") flag.StringVar(&cfg.jalienPort, "jalien-port", os.Getenv("JALIEN_PORT"), "JAliEn port (default from $JALIEN_PORT or internal default)") flag.StringVar(&cfg.clientCert, "cert", os.Getenv("X509_USER_CERT"), "Path to user X.509 certificate (default from $X509_USER_CERT)") @@ -82,8 +92,12 @@ func parseFlags() (*config, error) { if cfg.filesPerRun <= 0 { return nil, errors.New("flag --files-per-run must be > 0") } - if cfg.maxRunsPerBatch < 0 && cfg.maxFilesPerBatch < 0 { - return nil, errors.New("flag --max-runs-per-batch or --max-files-per-batch must be > 0") + if cfg.filesPerRunData <= 0 { + cfg.filesPerRunData = int(math.Ceil(float64(cfg.filesPerRun) / 3.0)) + fmt.Printf("Setting up default files per run data as: %d (ceil(filesPerRun / 3))\n", cfg.filesPerRunData) + } + if cfg.maxFilesPerBatch <= 0 { + return nil, errors.New("flag --max-files-per-batch must be > 0") } if cfg.outputDir == "" { return nil, errors.New("flag --output-dir is required") @@ -100,32 +114,129 @@ func parseFlags() (*config, error) { if cfg.clientKey == "" { return nil, errors.New("flag --key (or $X509_USER_KEY) is required") } + if err := ensureOutputDirs(cfg.outputDir); err != nil { + return nil, err + } return cfg, nil } func run(cfg *config) error { - // Ensure output directory exists and is writable. - if err := os.MkdirAll(cfg.outputDir, 0o755); err != nil { - return fmt.Errorf("cannot create output directory %q: %w", cfg.outputDir, err) - } - client, err := jalien.NewClient(cfg.jalienHost, cfg.jalienPort, cfg.clientCert, cfg.clientKey, cfg.caCertsDir, cfg.jalienTimeoutSeconds) if err != nil { return fmt.Errorf("cannot create JAliEn client: %w", err) } - aods, err := client.FindAODFiles(cfg.path) + allSimAodsByRun, err := getSimAodsByRun(cfg.path, cfg.minSizeMB, client) + if err != nil { + return err + } + + simEligibleRuns, err := filterEligibleRuns(allSimAodsByRun, cfg.runs, cfg.filesPerRun) + if err != nil { + return err + } + + // Uniformly select runs + selectedRunIdx := uniformIndices(uint64(len(simEligibleRuns)), uint64(cfg.runs)) + selectedRuns := make([]uint64, 0, cfg.runs) + for _, idx := range selectedRunIdx { + selectedRuns = append(selectedRuns, simEligibleRuns[idx]) + } + + runToSimAods := make(map[uint64][]jalien.AODFile, len(selectedRuns)) + for _, rn := range selectedRuns { + // At this point we know len(files) >= filesPerRun by construction. + idxs := uniformIndices(uint64(len(allSimAodsByRun[rn])), uint64(cfg.filesPerRun)) + selected := make([]jalien.AODFile, 0, cfg.filesPerRun) + for _, idx := range idxs { + selected = append(selected, allSimAodsByRun[rn][idx]) + } + runToSimAods[rn] = selected + } + + monc := monalisa.NewMonalisaClient(cfg.clientKey, cfg.clientCert, cfg.monalisaBaseUrl) + // Get runlist + runList, err := monc.GetRunList() + if err != nil { + return err + } + + // Get anchored data production (real data tag) + var passName string + mcTag := allSimAodsByRun[selectedRuns[0]][0].LHCPeriod + aodRow, err := monc.GetMCRow(mcTag) if err != nil { - return fmt.Errorf("failed to discover AOD files under %q: %w", cfg.path, err) + // try to obtain it from RunList + desc := runList.TagToDesc[mcTag] + re := regexp.MustCompile(`\bapass\d+(?:_\w+)?`) + m := re.FindString(desc) + + if m == "" { + return fmt.Errorf("error while obtaining anchored prod pass name") + } + + passName = m + } else { + passName = aodRow.PassName + } + + runToDataAods := make(map[uint64][]jalien.AODFile, len(selectedRuns)) + for _, sr := range selectedRuns { + dataTag := "" + for _, t := range runList.RunToTags[sr] { + if !runList.TagToIsMC[t] { + dataTag = t + break + } + } + if dataTag == "" { + return fmt.Errorf("Cannot find a anchored production period for run %d", sr) + } + + dataPath := getDataPath(dataTag, passName, sr) + aods, err := getAods(dataPath, cfg.minSizeMB/2, client) + if err != nil { + return err + } + + fCount := min(cfg.filesPerRunData, len(aods)) + idxs := uniformIndices(uint64(len(aods)), uint64(fCount)) + selected := make([]jalien.AODFile, 0, fCount) + for _, idx := range idxs { + selected = append(selected, aods[idx]) + } + + runToDataAods[sr] = selected + } + + if err := writeFilesInBatches(filepath.Join(cfg.outputDir, "sim"), flattenRunFiles(runToSimAods, selectedRuns), cfg.maxFilesPerBatch); err != nil { + return err + } + if err := writeFilesInBatches(filepath.Join(cfg.outputDir, "data"), flattenRunFiles(runToDataAods, selectedRuns), cfg.maxFilesPerBatch); err != nil { + return err + } + + // Persist non-sensitive configuration metadata alongside the batch files. + if err := writeMetadataFile(cfg); err != nil { + return err + } + + return nil +} + +func getAods(path string, minSizeMB float64, client *jalien.Client) ([]jalien.AODFile, error) { + aods, err := client.FindAODFiles(path) + if err != nil { + return nil, fmt.Errorf("failed to discover AOD files under %q: %w", path, err) } if len(aods) == 0 { - return fmt.Errorf("no AOD files found under path %q", cfg.path) + return nil, fmt.Errorf("no AOD files found under path %q", path) } // Apply optional minimum size filter (in MB) before any selection logic. - if cfg.minSizeMB > 0 { - minBytes := uint64(cfg.minSizeMB * 1024 * 1024) + if minSizeMB > 0 { + minBytes := uint64(minSizeMB * 1024 * 1024) filtered := make([]jalien.AODFile, 0, len(aods)) for _, f := range aods { if f.Size >= minBytes { @@ -133,17 +244,40 @@ func run(cfg *config) error { } } aods = filtered + + // sort for determinism + sort.Slice(aods, func(i, j int) bool { + if aods[i].AODNumber == aods[j].AODNumber { + return aods[i].Path < aods[j].Path + } + + return aods[i].AODNumber < aods[j].AODNumber + }) + if len(aods) == 0 { - return fmt.Errorf("no AOD files >= %.2f MB found under path %q", cfg.minSizeMB, cfg.path) + return nil, fmt.Errorf("no AOD files >= %.2f MB found under path %q", minSizeMB, path) } } + return aods, nil +} + +func getSimAodsByRun(path string, minSizeMB float64, client *jalien.Client) (map[uint64][]jalien.AODFile, error) { + aods, err := getAods(path, minSizeMB, client) + if err != nil { + return nil, err + } + // Group by run and sort. runsMap := make(map[uint64][]jalien.AODFile) for _, f := range aods { runsMap[f.RunNumber] = append(runsMap[f.RunNumber], f) } + return runsMap, nil +} + +func filterEligibleRuns(runsMap map[uint64][]jalien.AODFile, reqRunCount int, minFilesPerRun int) ([]uint64, error) { runNumbers := make([]uint64, 0, len(runsMap)) for rn := range runsMap { runNumbers = append(runNumbers, rn) @@ -154,129 +288,43 @@ func run(cfg *config) error { // not fail later when selecting files-per-run. eligibleRuns := make([]uint64, 0, len(runNumbers)) for _, rn := range runNumbers { - if len(runsMap[rn]) >= cfg.filesPerRun { + if len(runsMap[rn]) >= minFilesPerRun { eligibleRuns = append(eligibleRuns, rn) } } - if len(eligibleRuns) < cfg.runs { - return fmt.Errorf( - "requested %d runs with at least %d AOD files each, but only %d runs satisfy this under %q", - cfg.runs, cfg.filesPerRun, len(eligibleRuns), cfg.path, - ) - } - - selectedRunIdx := uniformIndices(len(eligibleRuns), cfg.runs) - selectedRuns := make([]uint64, 0, cfg.runs) - for _, idx := range selectedRunIdx { - selectedRuns = append(selectedRuns, eligibleRuns[idx]) - } - - // For determinism, within each selected run sort AODs by AODNumber then by path. - selectedFilesByRun := make(map[uint64][]jalien.AODFile, len(selectedRuns)) - for _, rn := range selectedRuns { - files := append([]jalien.AODFile(nil), runsMap[rn]...) - sort.Slice(files, func(i, j int) bool { - if files[i].AODNumber == files[j].AODNumber { - return files[i].Path < files[j].Path - } - return files[i].AODNumber < files[j].AODNumber - }) - - // At this point we know len(files) >= filesPerRun by construction. - idxs := uniformIndices(len(files), cfg.filesPerRun) - selected := make([]jalien.AODFile, 0, cfg.filesPerRun) - for _, idx := range idxs { - selected = append(selected, files[idx]) - } - selectedFilesByRun[rn] = selected - } - - // Build batches of runs. - if cfg.maxRunsPerBatch <= 0 && cfg.maxFilesPerBatch <= 0 { - return errors.New("flag --max-runs-per-batch or --max-files-per-batch must be > 0") - } - - if cfg.maxRunsPerBatch > 0 { - batchCount := int(math.Ceil(float64(len(selectedRuns)) / float64(cfg.maxRunsPerBatch))) - padding := len(fmt.Sprintf("%d", batchCount)) // number of digits for zero-padding - - batchIdx := 0 - for start := 0; start < len(selectedRuns); start += cfg.maxRunsPerBatch { - end := start + cfg.maxRunsPerBatch - if end > len(selectedRuns) { - end = len(selectedRuns) - } - batchRuns := selectedRuns[start:end] - batchFiles := make([]jalien.AODFile, 0, len(batchRuns)*cfg.filesPerRun) - for _, br := range batchRuns { - batchFiles = append(batchFiles, selectedFilesByRun[br]...) - } - - batchIdx++ - filename := fmt.Sprintf("batch_%0*d.txt", padding, batchIdx) - fullPath := filepath.Join(cfg.outputDir, filename) - - if err := writeBatchFile(fullPath, batchFiles); err != nil { - return err - } - } - } else if cfg.maxFilesPerBatch > 0 { - batchCount := int(math.Ceil(float64(len(selectedFilesByRun)) / float64(cfg.maxFilesPerBatch))) - padding := len(fmt.Sprintf("%d", batchCount)) // number of digits for zero-padding - files := make([]jalien.AODFile, 0, len(selectedFilesByRun)*cfg.filesPerRun) - for _, f := range selectedFilesByRun { - files = append(files, f...) - } - - batchIdx := 0 - for start := 0; start < len(files); start += cfg.maxFilesPerBatch { - end := start + cfg.maxFilesPerBatch - if end > len(files) { - end = len(files) - } - batchFiles := files[start:end] - - batchIdx++ - filename := fmt.Sprintf("batch_%0*d.txt", padding, batchIdx) - fullPath := filepath.Join(cfg.outputDir, filename) - - if err := writeBatchFile(fullPath, batchFiles); err != nil { - return err - } - } - } - // Persist non-sensitive configuration metadata alongside the batch files. - if err := writeMetadataFile(cfg); err != nil { - return err + if len(eligibleRuns) < reqRunCount { + return nil, fmt.Errorf( + "requested %d runs with at least %d AOD files each, but only %d runs satisfy this condition", + reqRunCount, minFilesPerRun, len(eligibleRuns)) } - return nil + return eligibleRuns, nil } // uniformIndices returns k indices in [0, n) that are as uniformly spaced as // possible over the interval when n >= k > 0. The result is strictly // increasing and deterministic. -func uniformIndices(n, k int) []int { +func uniformIndices(n, k uint64) []uint64 { if k <= 0 || n <= 0 { - return []int{} + return []uint64{} } if k == 1 { - return []int{n / 2} + return []uint64{n / 2} } step := float64(n) / float64(k) offset := step / 2.0 - indices := make([]int, 0, k) - prev := -1 - for i := 0; i < k; i++ { - x := int(offset + float64(i)*step) + indices := make([]uint64, 0, k) + prev := n + 1 + for i := uint64(0); i < k; i++ { + x := uint64(offset + float64(i)*step) if x >= n { x = n - 1 } // Ensure strict monotonicity in the unlikely case rounding produces duplicates. - if x <= prev { + if prev < n && x <= prev { x = prev + 1 if x >= n { x = n - 1 @@ -285,10 +333,36 @@ func uniformIndices(n, k int) []int { indices = append(indices, x) prev = x } + return indices } -func writeBatchFile(path string, batchFiles []jalien.AODFile) error { +func flattenRunFiles(runToAods map[uint64][]jalien.AODFile, runOrder []uint64) []jalien.AODFile { + flat := make([]jalien.AODFile, 0, len(runOrder)) + for _, rn := range runOrder { + flat = append(flat, runToAods[rn]...) + } + + return flat +} + +func writeFilesInBatches(outputDir string, files []jalien.AODFile, maxFilesPerBatch int) error { + batchCount := (len(files) + maxFilesPerBatch - 1) / maxFilesPerBatch + padding := len(fmt.Sprintf("%d", batchCount)) + + for start, batchIdx := 0, 1; start < len(files); start, batchIdx = start+maxFilesPerBatch, batchIdx+1 { + end := min(start+maxFilesPerBatch, len(files)) + filename := fmt.Sprintf("batch_%0*d.txt", padding, batchIdx) + if err := writeBatchFile(outputDir, filename, files[start:end]); err != nil { + return err + } + } + + return nil +} + +func writeBatchFile(output_dir, filename string, batchFiles []jalien.AODFile) error { + path := filepath.Join(output_dir, filename) f, err := os.Create(path) if err != nil { return fmt.Errorf("cannot create batch file %q: %w", path, err) @@ -308,12 +382,13 @@ func writeBatchFile(path string, batchFiles []jalien.AODFile) error { func writeMetadataFile(cfg *config) error { meta := metadata{ - Path: cfg.path, - Runs: cfg.runs, - FilesPerRun: cfg.filesPerRun, - MaxRunsPerBatch: cfg.maxRunsPerBatch, - MinSizeMB: cfg.minSizeMB, - Timestamp: time.Now().Format(time.RFC3339), + Path: cfg.path, + Runs: cfg.runs, + FilesPerRun: cfg.filesPerRun, + FilesPerRunData: cfg.filesPerRunData, + MaxFilesPerBatch: cfg.maxFilesPerBatch, + MinSizeMB: cfg.minSizeMB, + Timestamp: time.Now().Format(time.RFC3339), } path := filepath.Join(cfg.outputDir, "metadata.json") @@ -333,3 +408,38 @@ func writeMetadataFile(cfg *config) error { return nil } + +func ensureOutputDirs(outputDir string) error { + // 0o750: owner rwx, group rx, others no access. Keeps output private while + // still allowing read/execute traversal for shared project group. + for _, dir := range []string{ + outputDir, + filepath.Join(outputDir, "sim"), + filepath.Join(outputDir, "data"), + } { + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("cannot prepare output directory %q: %w", dir, err) + } + } + + metadataPath := filepath.Join(outputDir, "metadata.json") + testFile, err := os.CreateTemp(outputDir, ".write-check-*") + if err != nil { + return fmt.Errorf("output directory %q not writable: %w", outputDir, err) + } + testPath := testFile.Name() + _ = testFile.Close() + _ = os.Remove(testPath) + // Ensure we can replace/create metadata file later. + if _, err := os.Stat(metadataPath); err == nil { + f, err := os.OpenFile(metadataPath, os.O_WRONLY, 0) + if err != nil { + return fmt.Errorf("metadata file %q not writable: %w", metadataPath, err) + } + _ = f.Close() + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("cannot stat metadata file %q: %w", metadataPath, err) + } + + return nil +} diff --git a/go.mod b/go.mod index 5f58a44..86d9827 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,17 @@ module github.com/mytkom/AliceTraINT -go 1.22.5 +go 1.25.0 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 + github.com/PuerkitoBio/goquery v1.12.0 github.com/coreos/go-oidc v2.2.1+incompatible github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 github.com/russross/blackfriday/v2 v2.1.0 github.com/stretchr/testify v1.9.0 github.com/thomasdarimont/go-kc-example v0.0.0-20170529223628-e3951d8faa4c - golang.org/x/crypto v0.26.0 + golang.org/x/crypto v0.49.0 golang.org/x/oauth2 v0.22.0 gorm.io/driver/postgres v1.5.9 gorm.io/driver/sqlite v1.5.6 @@ -19,8 +20,8 @@ require ( ) require ( + github.com/andybalholm/cascadia v1.3.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/google/go-cmp v0.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect @@ -33,9 +34,10 @@ require ( github.com/pquerna/cachecontrol v0.2.0 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/stretchr/objx v0.5.2 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.24.0 // indirect - golang.org/x/text v0.17.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ae6e413..67160a6 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo= +github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/coreos/go-oidc v2.2.1+incompatible h1:mh48q/BqXqgjVHpy2ZY7WnWAbenxRjsz9N1i1YxjHAk= github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -52,16 +56,82 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/thomasdarimont/go-kc-example v0.0.0-20170529223628-e3951d8faa4c h1:lcylEBfIkt5tgRrDyh7N7a0U28d1bS7WurGpRl1fxJw= github.com/thomasdarimont/go-kc-example v0.0.0-20170529223628-e3951d8faa4c/go.mod h1:6c9GBv/SvzOyfPD5fEqQl9BCKS9IyO56JWB8BMp0CAc= -golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= -golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= -golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= -golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/applog/external.go b/internal/applog/external.go new file mode 100644 index 0000000..a0e966d --- /dev/null +++ b/internal/applog/external.go @@ -0,0 +1,42 @@ +package applog + +import ( + "log" + "time" +) + +func ExternalCall(service, operation, details string, fn func() error) error { + log.Printf("EXTERNAL %s %s started %s", service, operation, details) + start := time.Now() + + err := fn() + if err != nil { + log.Printf("EXTERNAL %s %s failed in %v %s: %v", service, operation, time.Since(start), details, err) + return err + } + + log.Printf("EXTERNAL %s %s completed in %v %s", service, operation, time.Since(start), details) + return nil +} + +func ExternalCallResult[T any](service, operation, details string, fn func() (T, error)) (T, error) { + log.Printf("EXTERNAL %s %s started %s", service, operation, details) + start := time.Now() + + result, err := fn() + if err != nil { + log.Printf("EXTERNAL %s %s failed in %v %s: %v", service, operation, time.Since(start), details, err) + return result, err + } + + log.Printf("EXTERNAL %s %s completed in %v %s", service, operation, time.Since(start), details) + return result, nil +} + +func ExternalCacheHit(service, operation, details string) { + log.Printf("EXTERNAL %s %s cache hit %s", service, operation, details) +} + +func Fallback(primaryOperation string, err error, fallbackOperation string) { + log.Printf("FALLBACK %s failed (%v), using %s", primaryOperation, err, fallbackOperation) +} diff --git a/internal/ccdb/requests.go b/internal/ccdb/requests.go index 135ebd5..7cc66b3 100644 --- a/internal/ccdb/requests.go +++ b/internal/ccdb/requests.go @@ -5,7 +5,6 @@ import ( "crypto/tls" "fmt" "io" - "log" "mime/multipart" "net/http" "path/filepath" @@ -106,6 +105,5 @@ func uploadFile(filename, url string, fileReader io.Reader, val, valEnd uint64, return fmt.Errorf("upload failed with status code %d", resp.StatusCode) } - log.Printf("Uploaded file: %s to path: %s\n", filename, uploadPath) return nil } diff --git a/internal/ccdb/run_information.go b/internal/ccdb/run_information.go index f9eac47..5cae3ed 100644 --- a/internal/ccdb/run_information.go +++ b/internal/ccdb/run_information.go @@ -24,14 +24,14 @@ func GetRunInformation(baseURL string, runNumber uint64) (*RunInformation, error return nil, err } - sorStr, sorOk := headers["Sor"] + sorStr, sorOk := headerValue(headers, "Sor", "SOR") if !sorOk { - return nil, fmt.Errorf("CCDB: SOR not present for run") + return nil, fmt.Errorf("CCDB: SOR not present for run %d (headers: %v)", runNumber, headers) } - eorStr, eorOk := headers["Eor"] + eorStr, eorOk := headerValue(headers, "Eor", "EOR") if !eorOk { - return nil, fmt.Errorf("CCDB: EOR not present for run") + return nil, fmt.Errorf("CCDB: EOR not present for run %d (headers: %v)", runNumber, headers) } sor, err := parseUint64(sorStr) @@ -59,3 +59,12 @@ func parseUint64(val string) (uint64, error) { return valUint, nil } + +func headerValue(headers map[string]string, keys ...string) (string, bool) { + for _, key := range keys { + if value, ok := headers[key]; ok && value != "" { + return value, true + } + } + return "", false +} diff --git a/internal/config/config.go b/internal/config/config.go index ddd6ede..ba4e083 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ type Config struct { JalienPort string JalienCertCADir string JalienTimeoutSeconds uint + MonalisaBaseURL string CCDBBaseURL string CCDBUploadSubdir string CertPath string @@ -60,7 +61,8 @@ func LoadConfig() *Config { JalienHost: getEnv("JALIEN_HOST", defaultJalienHost), JalienPort: getEnv("JALIEN_WSPORT", defaultJalienPort), JalienCertCADir: getEnv("JALIEN_CERT_CA_DIR", ""), - JalienTimeoutSeconds: getEnvAsUint("JALIEN_TIMEOUT_SECONDS", 60), + JalienTimeoutSeconds: getEnvAsUint("JALIEN_TIMEOUT_SECONDS", 180), + MonalisaBaseURL: getEnv("MONALISA_URL", "https://alimonitor.cern.ch"), CCDBBaseURL: getEnv("CCDB_URL", "http://ccdb-test.cern.ch:8080"), CCDBUploadSubdir: getEnv("CCDB_UPLOAD_SUBDIR", "/Users/m/mmytkows"), CertPath: getEnv("GRID_CERT_PATH", ""), diff --git a/internal/db/models/training_dataset.go b/internal/db/models/training_dataset.go index d2c321e..fb95001 100644 --- a/internal/db/models/training_dataset.go +++ b/internal/db/models/training_dataset.go @@ -1,14 +1,18 @@ package models import ( + "time" + "github.com/mytkom/AliceTraINT/internal/jalien" "gorm.io/gorm" ) type TrainingDataset struct { gorm.Model - Name string `gorm:"type:varchar(255);not null;uniqueIndex"` - AODFiles []jalien.AODFile `gorm:"serializer:json"` - UserId uint - User User + Name string `gorm:"type:varchar(255);not null;uniqueIndex"` + AODFiles []jalien.AODFile `gorm:"serializer:json"` + AnchorProdTimestampFrom time.Time + AnchorProdTimestampTo time.Time + UserId uint + User User } diff --git a/internal/handler/common.go b/internal/handler/common.go index 5e31e48..cdfc99a 100644 --- a/internal/handler/common.go +++ b/internal/handler/common.go @@ -1,8 +1,10 @@ package handler import ( + "errors" "log" "net/http" + "strings" "github.com/mytkom/AliceTraINT/internal/service" "github.com/mytkom/AliceTraINT/internal/utils" @@ -17,8 +19,8 @@ const ( // (so it can be swapped into a target), while for normal requests it uses // http.Error to render a minimal error page. func writeError(w http.ResponseWriter, r *http.Request, status int, publicMsg string, err error) { - log.Printf("HTTP ERROR %s %s [%d]: %s; internal error: %v", - r.Method, r.URL.Path, status, publicMsg, err) + log.Printf("HTTP ERROR %s %s [%d]: %s; internal error: %s", + r.Method, r.URL.Path, status, publicMsg, formatErrorChain(err)) if utils.IsHTMXRequest(r) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") @@ -34,15 +36,35 @@ func writeError(w http.ResponseWriter, r *http.Request, status int, publicMsg st // and safe public messages, then delegates to writeError for logging and // response formatting. func handleServiceError(w http.ResponseWriter, r *http.Request, err error) { - switch err.(type) { - case *service.ErrHandlerNotFound: + var notFound *service.ErrHandlerNotFound + var validation *service.ErrHandlerValidation + var external *service.ErrExternalServiceTimeout + var internal *service.ErrInternal + + switch { + case errors.As(err, ¬Found): writeError(w, r, http.StatusNotFound, err.Error(), err) - case *service.ErrHandlerValidation: + case errors.As(err, &validation): writeError(w, r, http.StatusUnprocessableEntity, err.Error(), err) - case *service.ErrExternalServiceTimeout: + case errors.As(err, &external): writeError(w, r, http.StatusServiceUnavailable, err.Error(), err) + case errors.As(err, &internal): + writeError(w, r, http.StatusInternalServerError, internal.PublicMessage(), err) default: - // Do not leak internal error details to the client. writeError(w, r, http.StatusInternalServerError, "unexpected internal server error", err) } } + +func formatErrorChain(err error) string { + if err == nil { + return "" + } + + parts := make([]string, 0, 4) + for err != nil { + parts = append(parts, err.Error()) + err = errors.Unwrap(err) + } + + return strings.Join(parts, " -> ") +} diff --git a/internal/handler/training_dataset_handler.go b/internal/handler/training_dataset_handler.go index a66bf15..e170901 100644 --- a/internal/handler/training_dataset_handler.go +++ b/internal/handler/training_dataset_handler.go @@ -258,12 +258,12 @@ func (h *TrainingDatasetHandler) SelectRandomAods(w http.ResponseWriter, r *http } } -func InitTrainingDatasetRoutes(mux *http.ServeMux, env *environment.Env, jalien service.IJAliEnService, routeCache *utils.Cache) { +func InitTrainingDatasetRoutes(mux *http.ServeMux, env *environment.Env, jalien service.IJAliEnService, ccdbService service.ICCDBService, monalisaService service.IMonalisaService, routeCache *utils.Cache) { prefix := "training-datasets" tjh := &TrainingDatasetHandler{ Env: env, - Service: service.NewTrainingDatasetService(env.RepositoryContext, jalien), + Service: service.NewTrainingDatasetService(env.RepositoryContext, jalien, ccdbService, monalisaService), } cache := routeCache diff --git a/internal/handler/training_task_handler.go b/internal/handler/training_task_handler.go index f505dff..6115c39 100644 --- a/internal/handler/training_task_handler.go +++ b/internal/handler/training_task_handler.go @@ -3,6 +3,7 @@ package handler import ( "encoding/json" "fmt" + "html/template" "net/http" "strconv" @@ -86,11 +87,12 @@ func (h *TrainingTaskHandler) UploadToCCDB(w http.ResponseWriter, r *http.Reques func (h *TrainingTaskHandler) Show(w http.ResponseWriter, r *http.Request) { type TemplateData struct { - Title string - TrainingTask models.TrainingTask - ImageFiles []models.TrainingTaskResult - OnnxFiles []models.TrainingTaskResult - LogFiles []models.TrainingTaskResult + Title string + TrainingTask models.TrainingTask + ImageFiles []models.TrainingTaskResult + OnnxFiles []models.TrainingTaskResult + LogFiles []models.TrainingTaskResult + HasConfiguration bool } idStr := r.PathValue("id") @@ -107,11 +109,12 @@ func (h *TrainingTaskHandler) Show(w http.ResponseWriter, r *http.Request) { } err = h.ExecuteTemplate(w, "training-tasks_show", TemplateData{ - Title: "Training Tasks", - TrainingTask: *tt.TrainingTask, - ImageFiles: tt.ImageFiles, - OnnxFiles: tt.OnnxFiles, - LogFiles: tt.LogFiles, + Title: "Training Tasks", + TrainingTask: *tt.TrainingTask, + ImageFiles: tt.ImageFiles, + OnnxFiles: tt.OnnxFiles, + LogFiles: tt.LogFiles, + HasConfiguration: utils.HasConfiguration(tt.TrainingTask.Configuration), }) if err != nil { @@ -121,9 +124,12 @@ func (h *TrainingTaskHandler) Show(w http.ResponseWriter, r *http.Request) { func (h *TrainingTaskHandler) New(w http.ResponseWriter, r *http.Request) { type TemplateData struct { - Title string - TrainingDatasets []models.TrainingDataset - FieldConfigs service.NNFieldConfigs + Title string + TrainingDatasets []models.TrainingDataset + FieldGroups []service.NNFieldGroup + PrefillConfig template.JS + PrefillDatasetId uint + PrefillNameSuggest string } user, ok := middleware.GetLoggedUser(r) @@ -138,11 +144,30 @@ func (h *TrainingTaskHandler) New(w http.ResponseWriter, r *http.Request) { return } - err = h.ExecuteTemplate(w, "training-tasks_new", TemplateData{ + data := TemplateData{ Title: "Create New Training Task!", TrainingDatasets: ttHelpers.TrainingDatasets, - FieldConfigs: ttHelpers.FieldConfigs, - }) + FieldGroups: ttHelpers.FieldGroups, + PrefillConfig: "null", + } + + if fromTask := r.URL.Query().Get("fromTask"); fromTask != "" { + fromID, parseErr := strconv.ParseUint(fromTask, 10, 32) + if parseErr != nil { + writeError(w, r, http.StatusUnprocessableEntity, "invalid fromTask id", parseErr) + return + } + source, getErr := h.Service.GetByID(uint(fromID)) + if getErr != nil { + handleServiceError(w, r, getErr) + return + } + data.PrefillConfig = utils.ConfigJSON(source.TrainingTask.Configuration) + data.PrefillDatasetId = source.TrainingTask.TrainingDatasetId + data.PrefillNameSuggest = source.TrainingTask.Name + " (copy)" + } + + err = h.ExecuteTemplate(w, "training-tasks_new", data) if err != nil { writeError(w, r, http.StatusInternalServerError, "unexpected internal server error", err) return @@ -174,10 +199,10 @@ func (h *TrainingTaskHandler) Create(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) } -func InitTrainingTaskRoutes(mux *http.ServeMux, env *environment.Env, ccdbService service.ICCDBService, jalienService service.IJAliEnService, fileService service.IFileService, nnArch service.INNArchService) { +func InitTrainingTaskRoutes(mux *http.ServeMux, env *environment.Env, ccdbService service.ICCDBService, monalisaService service.IMonalisaService, fileService service.IFileService, nnArch service.INNArchService) { prefix := "training-tasks" - ttService := service.NewTrainingTaskService(env.RepositoryContext, ccdbService, jalienService, fileService, nnArch) + ttService := service.NewTrainingTaskService(env.RepositoryContext, ccdbService, monalisaService, fileService, nnArch) tjh := NewTrainingTaskHandler(env, ttService) authMw := middleware.NewAuthMw(env.IAuthService, true) diff --git a/internal/jalien/aod_summary.go b/internal/jalien/aod_summary.go new file mode 100644 index 0000000..fbb5eb1 --- /dev/null +++ b/internal/jalien/aod_summary.go @@ -0,0 +1,106 @@ +package jalien + +import "sort" + +type AODPeriodSummary struct { + LHCPeriod string + FileCount int + TotalSize uint64 + Runs []AODRunSummary +} + +type AODRunSummary struct { + LHCPeriod string + RunNumber uint64 + FileCount int + TotalSize uint64 +} + +func SummarizeByPeriod(files []AODFile) []AODPeriodSummary { + if len(files) == 0 { + return nil + } + + periodMap := make(map[string]*AODPeriodSummary) + for _, file := range files { + summary, ok := periodMap[file.LHCPeriod] + if !ok { + summary = &AODPeriodSummary{LHCPeriod: file.LHCPeriod} + periodMap[file.LHCPeriod] = summary + } + summary.FileCount++ + summary.TotalSize += file.Size + } + + summaries := make([]AODPeriodSummary, 0, len(periodMap)) + for _, summary := range periodMap { + summaries = append(summaries, *summary) + } + + sort.Slice(summaries, func(i, j int) bool { + return summaries[i].LHCPeriod < summaries[j].LHCPeriod + }) + + return summaries +} + +func SummarizeByPeriodAndRun(files []AODFile) []AODPeriodSummary { + if len(files) == 0 { + return nil + } + + type runKey struct { + period string + run uint64 + } + + runMap := make(map[runKey]*AODRunSummary) + periodTotals := make(map[string]*AODPeriodSummary) + + for _, file := range files { + key := runKey{period: file.LHCPeriod, run: file.RunNumber} + + runSummary, ok := runMap[key] + if !ok { + runSummary = &AODRunSummary{ + LHCPeriod: file.LHCPeriod, + RunNumber: file.RunNumber, + } + runMap[key] = runSummary + } + runSummary.FileCount++ + runSummary.TotalSize += file.Size + + periodSummary, ok := periodTotals[file.LHCPeriod] + if !ok { + periodSummary = &AODPeriodSummary{LHCPeriod: file.LHCPeriod} + periodTotals[file.LHCPeriod] = periodSummary + } + periodSummary.FileCount++ + periodSummary.TotalSize += file.Size + } + + periodSummaries := make([]AODPeriodSummary, 0, len(periodTotals)) + for _, periodSummary := range periodTotals { + runs := make([]AODRunSummary, 0) + for key, runSummary := range runMap { + if key.period != periodSummary.LHCPeriod { + continue + } + runs = append(runs, *runSummary) + } + + sort.Slice(runs, func(i, j int) bool { + return runs[i].RunNumber < runs[j].RunNumber + }) + + periodSummary.Runs = runs + periodSummaries = append(periodSummaries, *periodSummary) + } + + sort.Slice(periodSummaries, func(i, j int) bool { + return periodSummaries[i].LHCPeriod < periodSummaries[j].LHCPeriod + }) + + return periodSummaries +} diff --git a/internal/jalien/aod_summary_test.go b/internal/jalien/aod_summary_test.go new file mode 100644 index 0000000..fed24ca --- /dev/null +++ b/internal/jalien/aod_summary_test.go @@ -0,0 +1,52 @@ +package jalien_test + +import ( + "testing" + + "github.com/mytkom/AliceTraINT/internal/jalien" + "github.com/stretchr/testify/assert" +) + +func TestSummarizeByPeriod(t *testing.T) { + files := []jalien.AODFile{ + {LHCPeriod: "LHC24f3", RunNumber: 1, Size: 100}, + {LHCPeriod: "LHC24f3", RunNumber: 2, Size: 200}, + {LHCPeriod: "LHC24b1b", RunNumber: 3, Size: 300}, + } + + summaries := jalien.SummarizeByPeriod(files) + + assert.Len(t, summaries, 2) + assert.Equal(t, "LHC24b1b", summaries[0].LHCPeriod) + assert.Equal(t, 1, summaries[0].FileCount) + assert.Equal(t, uint64(300), summaries[0].TotalSize) + assert.Equal(t, "LHC24f3", summaries[1].LHCPeriod) + assert.Equal(t, 2, summaries[1].FileCount) + assert.Equal(t, uint64(300), summaries[1].TotalSize) +} + +func TestSummarizeByPeriodAndRun(t *testing.T) { + files := []jalien.AODFile{ + {LHCPeriod: "LHC24f3", RunNumber: 2, Size: 100}, + {LHCPeriod: "LHC24f3", RunNumber: 1, Size: 200}, + {LHCPeriod: "LHC24f3", RunNumber: 1, Size: 50}, + {LHCPeriod: "LHC24b1b", RunNumber: 9, Size: 300}, + } + + summaries := jalien.SummarizeByPeriodAndRun(files) + + assert.Len(t, summaries, 2) + assert.Equal(t, "LHC24b1b", summaries[0].LHCPeriod) + assert.Equal(t, 1, summaries[0].FileCount) + assert.Len(t, summaries[0].Runs, 1) + assert.Equal(t, uint64(9), summaries[0].Runs[0].RunNumber) + + assert.Equal(t, "LHC24f3", summaries[1].LHCPeriod) + assert.Equal(t, 3, summaries[1].FileCount) + assert.Equal(t, uint64(350), summaries[1].TotalSize) + assert.Len(t, summaries[1].Runs, 2) + assert.Equal(t, uint64(1), summaries[1].Runs[0].RunNumber) + assert.Equal(t, 2, summaries[1].Runs[0].FileCount) + assert.Equal(t, uint64(250), summaries[1].Runs[0].TotalSize) + assert.Equal(t, uint64(2), summaries[1].Runs[1].RunNumber) +} diff --git a/internal/jalien/commands.go b/internal/jalien/commands.go index 7c167de..d63364d 100644 --- a/internal/jalien/commands.go +++ b/internal/jalien/commands.go @@ -122,41 +122,82 @@ func (client *Client) FindAODFiles(path string) ([]AODFile, error) { return nil, err } - matcher := newAODMatcher() aods := make([]AODFile, 0, len(rawResults)) - for _, r := range rawResults { - // Skip directories if type information is present. - if t := getStringField(r, "type"); t != "" { - if strings.ToLower(t) != "f" { + if strings.Contains(path, "/alice/data") { + // process data + matcher := newDataAODMatcher() + + for i, r := range rawResults { + // Skip directories if type information is present. + if t := getStringField(r, "type"); t != "" { + if strings.ToLower(t) != "f" { + continue + } + } + + aodPath := getStringField(r, "lfn") + + pathVariables, err := matcher.MatchAO2DPath(aodPath) + if err != nil { + print("Skipping file: %s, error: %s", aodPath, err.Error()) + continue + } + if pathVariables == nil { continue } - } - aodPath := getStringField(r, "lfn") + size, err := getUint64Field(r, "size") + if err != nil { + return nil, err + } - pathVariables, err := matcher.MatchAO2DPath(aodPath) - if err != nil { - print("Skipping file: %s, error: %s", aodPath, err.Error()) - continue - } - if pathVariables == nil { - continue + aods = append(aods, AODFile{ + Name: aodFilename, + Path: aodPath, + Size: size, + LHCPeriod: pathVariables.LHCPeriod, + RunNumber: pathVariables.RunNumber, + AODNumber: uint64(i), + }) } + } else { + // process MC + matcher := newAODMatcher() + + for _, r := range rawResults { + // Skip directories if type information is present. + if t := getStringField(r, "type"); t != "" { + if strings.ToLower(t) != "f" { + continue + } + } - size, err := getUint64Field(r, "size") - if err != nil { - return nil, err - } + aodPath := getStringField(r, "lfn") + + pathVariables, err := matcher.MatchAO2DPath(aodPath) + if err != nil { + print("Skipping file: %s, error: %s", aodPath, err.Error()) + continue + } + if pathVariables == nil { + continue + } - aods = append(aods, AODFile{ - Name: aodFilename, - Path: aodPath, - Size: size, - LHCPeriod: pathVariables.LHCPeriod, - RunNumber: pathVariables.RunNumber, - AODNumber: pathVariables.AODNumber, - }) + size, err := getUint64Field(r, "size") + if err != nil { + return nil, err + } + + aods = append(aods, AODFile{ + Name: aodFilename, + Path: aodPath, + Size: size, + LHCPeriod: pathVariables.LHCPeriod, + RunNumber: pathVariables.RunNumber, + AODNumber: pathVariables.AODNumber, + }) + } } return aods, nil diff --git a/internal/jalien/filepath_aod_matcher.go b/internal/jalien/filepath_aod_matcher.go index fcc209b..e90db8a 100644 --- a/internal/jalien/filepath_aod_matcher.go +++ b/internal/jalien/filepath_aod_matcher.go @@ -15,6 +15,14 @@ type filepathAODMatcherResult struct { AODNumber uint64 } +func newDataAODMatcher() *filepathAODMatcher { + regexpPattern := `/(?PLHC[a-zA-Z0-9]+)/(?P\d+)/.*/(?P\d+)/AO2D\.root` + + return &filepathAODMatcher{ + regexpCompiled: regexp.MustCompile(regexpPattern), + } +} + func newAODMatcher() *filepathAODMatcher { regexpPattern := `/(?PLHC[a-zA-Z0-9]+)/(?:\d+/)?(?P\d+)/(?:AOD/)?(?P\d+)/AO2D\.root` diff --git a/internal/monalisa/scrape.go b/internal/monalisa/scrape.go new file mode 100644 index 0000000..aabc625 --- /dev/null +++ b/internal/monalisa/scrape.go @@ -0,0 +1,222 @@ +package monalisa + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "log" + "net/http" + "slices" + "strconv" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" +) + +const MC_ANCH_PASS_NAME_COL_ID = 5 +const MC_TAG_TARGET_STR = "alicemctag" +const MC_ANCH_TARGET_STR = "alicemcaprod" + +type HyperloopRunList struct { + RunToTags map[uint64][]string + TagToRuns map[string][]uint64 + TagToIsMC map[string]bool + TagToDesc map[string]string +} + +type hyperloopRunListRawEntry struct { + EntryType string `json:"type"` + RunListStringified string `json:"runlist"` + PeriodTag string `json:"period"` + Description string `json:"jt_description"` +} + +func (rl *HyperloopRunList) UnmarshalJSON(raw []byte) error { + var rawEntries []hyperloopRunListRawEntry + err := json.Unmarshal(raw, &rawEntries) + if err != nil { + return err + } + + rl.TagToIsMC = make(map[string]bool, len(rawEntries)) + rl.TagToDesc = make(map[string]string, len(rawEntries)) + rl.TagToRuns = make(map[string][]uint64, len(rawEntries)) + rl.RunToTags = make(map[uint64][]string, len(rawEntries)) + + for _, e := range rawEntries { + for rs := range strings.SplitSeq(e.RunListStringified, ",") { + rn, err := strconv.ParseUint(rs, 10, 64) + if err != nil { + return err + } + + // check if run is present before appending + if !slices.Contains(rl.TagToRuns[e.PeriodTag], rn) { + rl.TagToRuns[e.PeriodTag] = append(rl.TagToRuns[e.PeriodTag], rn) + } + + // check if tag is present before appending + if !slices.Contains(rl.RunToTags[rn], e.PeriodTag) { + rl.RunToTags[rn] = append(rl.RunToTags[rn], e.PeriodTag) + } + + rl.TagToIsMC[e.PeriodTag] = (e.EntryType == "MC") + rl.TagToDesc[e.PeriodTag] = e.Description + } + } + + return nil +} + +type MonalisaClient struct { + baseURL string + cert tls.Certificate + runListExpireAfterMinutes int64 + runList *HyperloopRunList + runListObtainedAt time.Time +} + +func NewMonalisaClient(keyPath, certPath, baseUrl string) *MonalisaClient { + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + log.Fatalf("cannot create MonalisaCert: %s", err.Error()) + } + return &MonalisaClient{ + baseURL: baseUrl, + cert: cert, + runListExpireAfterMinutes: 1440, // 24 Hours + } +} + +type MCRow struct { + MCTag string + AnchorProdTag string + PassName string +} + +func (c *MonalisaClient) GetMCRow(mcPeriod string) (*MCRow, error) { + url := fmt.Sprintf("%s/MC/?details=0&prodName=%s$", c.baseURL, mcPeriod) + + body := &bytes.Buffer{} + + request, err := http.NewRequest("GET", url, body) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + request.Header.Set("Content-Type", "application/json") + + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + Certificates: []tls.Certificate{c.cert}, + InsecureSkipVerify: true, + }, + }, + } + resp, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("failed to upload file: %w", err) + } + + //nolint:errcheck + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("upload failed with status code %d", resp.StatusCode) + } + + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + return nil, err + } + + mcTag := doc.Find(fmt.Sprintf(".link[target=%s]", MC_TAG_TARGET_STR)). + First(). + Text() + + if mcTag == "" { + return nil, fmt.Errorf("cannot obtain mcRow") + } + + anchorProd := doc.Find(fmt.Sprintf(".link[target=%s]", MC_ANCH_TARGET_STR)). + First(). + Text() + + if anchorProd == "" { + return nil, fmt.Errorf("cannot obtain mcRow") + } + + passName := doc.Find("tbody tr.table_row td"). + Get(MC_ANCH_PASS_NAME_COL_ID). + FirstChild. + Data + + // trim string + passName = strings.Trim(passName, " \n\t") + + // debug + // fmt.Printf("MC Tag: %s, Anchor prod: %s, pass name: %s", mcTag, anchorProd, passName) + + return &MCRow{ + AnchorProdTag: anchorProd, + MCTag: mcTag, + PassName: passName, + }, nil +} + +func (c *MonalisaClient) GetRunList() (*HyperloopRunList, error) { + expireTimestamp := int64(c.runListObtainedAt.Unix()) + (c.runListExpireAfterMinutes * int64(time.Minute)) + if c.runList != nil && + time.Now().Unix() <= expireTimestamp { + return c.runList, nil + } + + url := fmt.Sprintf("%s/alihyperloop-data/runlist/list-runlist.jsp?lists=runlists", c.baseURL) + + body := &bytes.Buffer{} + req, err := http.NewRequest("GET", url, body) + if err != nil { + return nil, fmt.Errorf("problem with obtaining run list: %s", err.Error()) + } + + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + Certificates: []tls.Certificate{c.cert}, + InsecureSkipVerify: true, + }, + }, + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to get run list from hyperloop page: %w", err) + } + + //nolint:errcheck + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("get run list failed with status code %d", resp.StatusCode) + } + + rawJson := bytes.Buffer{} + _, err = rawJson.ReadFrom(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading body of response failed %s", err.Error()) + } + + var runList HyperloopRunList + err = json.Unmarshal(rawJson.Bytes(), &runList) + if err != nil { + return nil, fmt.Errorf("unmarshalling response from JSON failed %s", err.Error()) + } + + c.runList = &runList + c.runListObtainedAt = time.Now() + + return c.runList, nil +} diff --git a/internal/monalisa/scrape_test.go b/internal/monalisa/scrape_test.go new file mode 100644 index 0000000..ed2aaf8 --- /dev/null +++ b/internal/monalisa/scrape_test.go @@ -0,0 +1,170 @@ +package monalisa + +import ( + "crypto/tls" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestHyperloopRunListUnmarshalJSON(t *testing.T) { + raw := []byte(`[ + {"type":"MC","runlist":"101,102,101","period":"LHC24m"}, + {"type":"DATA","runlist":"102,103","period":"LHC24data"} + ]`) + + var got HyperloopRunList + if err := got.UnmarshalJSON(raw); err != nil { + t.Fatalf("UnmarshalJSON() error = %v, want nil", err) + } + + if len(got.TagToRuns["LHC24m"]) != 2 { + t.Fatalf("TagToRuns[LHC24m] len = %d, want 2", len(got.TagToRuns["LHC24m"])) + } + if len(got.RunToTags[102]) != 2 { + t.Fatalf("RunToTags[102] len = %d, want 2", len(got.RunToTags[102])) + } + if !got.TagToIsMC["LHC24m"] { + t.Fatalf("TagToIsMC[LHC24m] = false, want true") + } + if got.TagToIsMC["LHC24data"] { + t.Fatalf("TagToIsMC[LHC24data] = true, want false") + } +} + +func TestHyperloopRunListUnmarshalJSONBadRunNumber(t *testing.T) { + raw := []byte(`[{"type":"MC","runlist":"123,nope","period":"LHC24m"}]`) + + var got HyperloopRunList + if err := got.UnmarshalJSON(raw); err == nil { + t.Fatalf("UnmarshalJSON() error = nil, want non-nil") + } +} + +func TestGetRunListCachesResult(t *testing.T) { + requestCount := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + if r.URL.Path != "/alihyperloop-data/runlist/list-runlist.jsp" { + t.Fatalf("path = %q, want runlist endpoint", r.URL.Path) + } + _, _ = w.Write([]byte(`[{"type":"MC","runlist":"2001,2002","period":"LHC24m"}]`)) + })) + defer server.Close() + + c := &MonalisaClient{ + baseURL: server.URL, + cert: tls.Certificate{}, + runListExpireAfterMinutes: 60, + } + + first, err := c.GetRunList() + if err != nil { + t.Fatalf("GetRunList() first error = %v, want nil", err) + } + second, err := c.GetRunList() + if err != nil { + t.Fatalf("GetRunList() second error = %v, want nil", err) + } + + if first != second { + t.Fatalf("cached pointer mismatch: first=%p second=%p", first, second) + } + if requestCount != 1 { + t.Fatalf("requestCount = %d, want 1", requestCount) + } +} + +func TestGetRunListRefreshesWhenExpired(t *testing.T) { + requestCount := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + _, _ = w.Write([]byte(fmt.Sprintf(`[{"type":"MC","runlist":"%d","period":"LHC24m"}]`, 3000+requestCount))) + })) + defer server.Close() + + c := &MonalisaClient{ + baseURL: server.URL, + cert: tls.Certificate{}, + runListExpireAfterMinutes: -1, + runListObtainedAt: time.Now(), + runList: &HyperloopRunList{ + RunToTags: map[uint64][]string{1: {"old"}}, + TagToRuns: map[string][]uint64{"old": {1}}, + TagToIsMC: map[string]bool{"old": true}, + }, + } + + first, err := c.GetRunList() + if err != nil { + t.Fatalf("GetRunList() first error = %v, want nil", err) + } + second, err := c.GetRunList() + if err != nil { + t.Fatalf("GetRunList() second error = %v, want nil", err) + } + + if requestCount != 2 { + t.Fatalf("requestCount = %d, want 2", requestCount) + } + if first == second { + t.Fatalf("expected refreshed pointer, got same pointer %p", first) + } +} + +func TestGetMCRowParsesHTML(t *testing.T) { + const html = ` + + LHC24m + LHC24anchor + + + + + + +
c0c1c2c3c4 passAOD
+` + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(html)) + })) + defer server.Close() + + c := &MonalisaClient{ + baseURL: server.URL, + cert: tls.Certificate{}, + } + + row, err := c.GetMCRow("LHC24m") + if err != nil { + t.Fatalf("GetMCRow() error = %v, want nil", err) + } + if row.MCTag != "LHC24m" { + t.Fatalf("MCTag = %q, want %q", row.MCTag, "LHC24m") + } + if row.AnchorProdTag != "LHC24anchor" { + t.Fatalf("AnchorProdTag = %q, want %q", row.AnchorProdTag, "LHC24anchor") + } + if row.PassName != "passAOD" { + t.Fatalf("PassName = %q, want %q", row.PassName, "passAOD") + } +} + +func TestGetMCRowNon200(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + c := &MonalisaClient{ + baseURL: server.URL, + cert: tls.Certificate{}, + } + + if _, err := c.GetMCRow("LHC24m"); err == nil { + t.Fatalf("GetMCRow() error = nil, want non-nil") + } +} diff --git a/internal/router.go b/internal/router.go index 82a0abe..4b36f97 100644 --- a/internal/router.go +++ b/internal/router.go @@ -29,6 +29,7 @@ func NewRouter(cfg *config.Config, repoContext *repository.RepositoryContext, au ccdbService := service.NewCCDBService(env) jalienCache := utils.NewCache(time.Duration(cfg.JalienCacheMinutes) * time.Minute) jalienService := service.NewJAliEnService(env, jalienCache) + monalisaService := service.NewMonalisaService(env) nnArch := service.NewNNArchService(cfg.NNArchPath) // local file storage fileService := service.NewLocalFileService(cfg.DataDirPath) @@ -44,8 +45,8 @@ func NewRouter(cfg *config.Config, repoContext *repository.RepositoryContext, au // handlers' routes handler.InitLandingRoutes(mux, env) handler.InitDocsRoutes(mux, env) - handler.InitTrainingDatasetRoutes(mux, env, jalienService, jalienCache) - handler.InitTrainingTaskRoutes(mux, env, ccdbService, jalienService, fileService, nnArch) + handler.InitTrainingDatasetRoutes(mux, env, jalienService, ccdbService, monalisaService, jalienCache) + handler.InitTrainingTaskRoutes(mux, env, ccdbService, monalisaService, fileService, nnArch) handler.InitTrainingMachineRoutes(mux, env, hasher) handler.InitQueueRoutes(mux, env, fileService, hasher) diff --git a/internal/service/ccdb_service.go b/internal/service/ccdb_service.go index 12c1b26..9bc4fb4 100644 --- a/internal/service/ccdb_service.go +++ b/internal/service/ccdb_service.go @@ -6,6 +6,7 @@ import ( "io" "log" + "github.com/mytkom/AliceTraINT/internal/applog" "github.com/mytkom/AliceTraINT/internal/ccdb" "github.com/mytkom/AliceTraINT/internal/environment" "github.com/stretchr/testify/mock" @@ -36,18 +37,22 @@ func NewCCDBService(env *environment.Env) *CCDBService { } func (s *CCDBService) GetRunInformation(runNumber uint64) (*ccdb.RunInformation, error) { - return ccdb.GetRunInformation(s.baseURL, runNumber) + return applog.ExternalCallResult("CCDB", "GetRunInformation", fmt.Sprintf("run=%d", runNumber), func() (*ccdb.RunInformation, error) { + return ccdb.GetRunInformation(s.baseURL, runNumber) + }) } func (s *CCDBService) UploadFile(sor, eor uint64, filename string, file io.Reader) error { - return ccdb.UploadFile( - fmt.Sprintf("%s/%s", s.baseURL, s.uploadSubdir), - &s.cert, - sor, - eor, - filename, - file, - ) + return applog.ExternalCall("CCDB", "UploadFile", fmt.Sprintf("file=%s sor=%d eor=%d", filename, sor, eor), func() error { + return ccdb.UploadFile( + fmt.Sprintf("%s/%s", s.baseURL, s.uploadSubdir), + &s.cert, + sor, + eor, + filename, + file, + ) + }) } type MockCCDBService struct { diff --git a/internal/service/handler_errors.go b/internal/service/handler_errors.go index d68257b..0e9fc4f 100644 --- a/internal/service/handler_errors.go +++ b/internal/service/handler_errors.go @@ -6,18 +6,60 @@ import ( "os" ) -func handleCCDBError(err error) error { +type ErrInternal struct { + Op string + Err error +} + +func (e *ErrInternal) Error() string { + return fmt.Sprintf("%s: %v", e.Op, e.Err) +} + +func (e *ErrInternal) Unwrap() error { + return e.Err +} + +func (e *ErrInternal) PublicMessage() string { + return "unexpected internal server error" +} + +func InternalError(op string, err error) error { + if err == nil { + err = errors.New("unknown error") + } + return &ErrInternal{ + Op: op, + Err: err, + } +} + +func mapCCDBError(op string, err error) error { + if err == nil { + return nil + } if os.IsTimeout(err) { return errCCDBUnreachable } - return errInternalServerError + return InternalError(op, err) } -func handleJAlienError(err error) error { - print(err.Error()) +func mapJAlienError(_ string, err error) error { + if err == nil { + return nil + } return errJAlienUnreachable } +func mapMonalisaError(op string, err error) error { + if err == nil { + return nil + } + if os.IsTimeout(err) { + return errMonalisaUnreachable + } + return InternalError(op, err) +} + type ErrHandlerNotFound struct { Resource string } @@ -46,10 +88,6 @@ func (e *ErrHandlerValidation) Error() string { return fmt.Sprintf("%s %s", e.Field, e.Msg) } -var ( - errInternalServerError = errors.New("unexpected internal server error") -) - type ErrExternalServiceTimeout struct { Service string } diff --git a/internal/service/jalien_service.go b/internal/service/jalien_service.go index 1a1e982..2952299 100644 --- a/internal/service/jalien_service.go +++ b/internal/service/jalien_service.go @@ -2,10 +2,12 @@ package service import ( "encoding/json" + "fmt" "log" "path" "strings" + "github.com/mytkom/AliceTraINT/internal/applog" "github.com/mytkom/AliceTraINT/internal/environment" "github.com/mytkom/AliceTraINT/internal/jalien" "github.com/mytkom/AliceTraINT/internal/utils" @@ -45,30 +47,36 @@ func findAODFilesCacheKey(p string) string { func (s *JAliEnService) FindAODFiles(p string) ([]jalien.AODFile, error) { cacheKey := findAODFilesCacheKey(p) - if cacheKey == "" || s.aodCache == nil { - return s.client.FindAODFiles(p) - } - - if raw, ok := s.aodCache.Get(cacheKey); ok { - var files []jalien.AODFile - if err := json.Unmarshal([]byte(raw), &files); err == nil { - return files, nil + if cacheKey != "" && s.aodCache != nil { + if raw, ok := s.aodCache.Get(cacheKey); ok { + var files []jalien.AODFile + if err := json.Unmarshal([]byte(raw), &files); err == nil { + applog.ExternalCacheHit("JAliEn", "FindAODFiles", fmt.Sprintf("path=%s", p)) + return files, nil + } } } - files, err := s.client.FindAODFiles(p) + files, err := applog.ExternalCallResult("JAliEn", "FindAODFiles", fmt.Sprintf("path=%s", p), func() ([]jalien.AODFile, error) { + return s.client.FindAODFiles(p) + }) if err != nil { return nil, err } - if b, err := json.Marshal(files); err == nil { - s.aodCache.Set(cacheKey, string(b)) + if cacheKey != "" && s.aodCache != nil { + if b, err := json.Marshal(files); err == nil { + s.aodCache.Set(cacheKey, string(b)) + } } + return files, nil } func (s *JAliEnService) ListAndParseDirectory(path string) (*jalien.DirectoryContents, error) { - return s.client.ListAndParseDirectory(path) + return applog.ExternalCallResult("JAliEn", "ListAndParseDirectory", fmt.Sprintf("path=%s", path), func() (*jalien.DirectoryContents, error) { + return s.client.ListAndParseDirectory(path) + }) } type MockJAliEnService struct { diff --git a/internal/service/monalisa_service.go b/internal/service/monalisa_service.go new file mode 100644 index 0000000..99fef60 --- /dev/null +++ b/internal/service/monalisa_service.go @@ -0,0 +1,68 @@ +package service + +import ( + "sync" + + "github.com/mytkom/AliceTraINT/internal/applog" + "github.com/mytkom/AliceTraINT/internal/environment" + "github.com/mytkom/AliceTraINT/internal/monalisa" + "github.com/stretchr/testify/mock" +) + +type IMonalisaService interface { + GetRunList() (*monalisa.HyperloopRunList, error) +} + +type MonalisaService struct { + client *monalisa.MonalisaClient + mu sync.RWMutex + cachedRunList *monalisa.HyperloopRunList +} + +func NewMonalisaService(env *environment.Env) *MonalisaService { + return &MonalisaService{ + client: monalisa.NewMonalisaClient(env.KeyPath, env.CertPath, env.MonalisaBaseURL), + } +} + +func (s *MonalisaService) GetRunList() (*monalisa.HyperloopRunList, error) { + s.mu.RLock() + if s.cachedRunList != nil { + runList := s.cachedRunList + s.mu.RUnlock() + applog.ExternalCacheHit("Monalisa", "GetRunList", "") + return runList, nil + } + s.mu.RUnlock() + + return applog.ExternalCallResult("Monalisa", "GetRunList", "", func() (*monalisa.HyperloopRunList, error) { + runList, err := s.client.GetRunList() + if err != nil { + return nil, err + } + + s.mu.Lock() + s.cachedRunList = runList + s.mu.Unlock() + + return runList, nil + }) +} + +type MockMonalisaService struct { + mock.Mock +} + +func NewMockMonalisaService() *MockMonalisaService { + return &MockMonalisaService{} +} + +func (s *MockMonalisaService) GetRunList() (*monalisa.HyperloopRunList, error) { + args := s.Called() + + if args.Error(1) != nil { + return nil, args.Error(1) + } + + return args.Get(0).(*monalisa.HyperloopRunList), args.Error(1) +} diff --git a/internal/service/nn_arch_service.go b/internal/service/nn_arch_service.go index 123c85d..f7179a9 100644 --- a/internal/service/nn_arch_service.go +++ b/internal/service/nn_arch_service.go @@ -11,20 +11,40 @@ type NNExpectedResults struct { Onnx map[string]string `json:"onnx"` } +type NNOption struct { + Value interface{} `json:"value"` + Label string `json:"label"` +} + +type NNShowWhen struct { + Field string `json:"field"` + Equals interface{} `json:"equals"` +} + type NNConfigField struct { + Name string `json:"name"` FullName string `json:"full_name"` Type string `json:"type"` DefaultValue interface{} `json:"default_value"` - Min interface{} `json:"min"` - Max interface{} `json:"max"` - Step interface{} `json:"step"` + Min interface{} `json:"min,omitempty"` + Max interface{} `json:"max,omitempty"` + Step interface{} `json:"step,omitempty"` + Options []NNOption `json:"options,omitempty"` + AllowNull bool `json:"allow_null,omitempty"` Description string `json:"description"` } -type NNFieldConfigs map[string]NNConfigField +type NNFieldGroup struct { + Name string `json:"name"` + Path string `json:"path"` + FullName string `json:"full_name"` + ShowWhen *NNShowWhen `json:"show_when,omitempty"` + Fields []NNConfigField `json:"fields,omitempty"` + Groups []NNFieldGroup `json:"groups,omitempty"` +} type NNArchSpec struct { - FieldConfigs NNFieldConfigs `json:"field_configs"` + FieldGroups []NNFieldGroup `json:"field_groups"` ExpectedResults NNExpectedResults `json:"expected_results"` } @@ -51,7 +71,7 @@ func loadSpec(filename string) (*NNArchSpec, error) { } type INNArchService interface { - GetFieldConfigs() NNFieldConfigs + GetFieldGroups() []NNFieldGroup GetExpectedResults() NNExpectedResults } @@ -71,8 +91,8 @@ func NewNNArchService(filename string) *NNArchService { } } -func (s *NNArchService) GetFieldConfigs() NNFieldConfigs { - return s.FieldConfigs +func (s *NNArchService) GetFieldGroups() []NNFieldGroup { + return s.FieldGroups } func (s *NNArchService) GetExpectedResults() NNExpectedResults { @@ -83,17 +103,17 @@ type NNArchServiceInMemory struct { *NNArchSpec } -func NewNNArchServiceInMemory(fieldConfigs *NNFieldConfigs, expectedResults *NNExpectedResults) *NNArchServiceInMemory { +func NewNNArchServiceInMemory(fieldGroups []NNFieldGroup, expectedResults *NNExpectedResults) *NNArchServiceInMemory { return &NNArchServiceInMemory{ NNArchSpec: &NNArchSpec{ - FieldConfigs: *fieldConfigs, + FieldGroups: fieldGroups, ExpectedResults: *expectedResults, }, } } -func (s *NNArchServiceInMemory) GetFieldConfigs() NNFieldConfigs { - return s.FieldConfigs +func (s *NNArchServiceInMemory) GetFieldGroups() []NNFieldGroup { + return s.FieldGroups } func (s *NNArchServiceInMemory) GetExpectedResults() NNExpectedResults { diff --git a/internal/service/nn_arch_service_test.go b/internal/service/nn_arch_service_test.go new file mode 100644 index 0000000..0f37814 --- /dev/null +++ b/internal/service/nn_arch_service_test.go @@ -0,0 +1,30 @@ +package service_test + +import ( + "testing" + + "github.com/mytkom/AliceTraINT/internal/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNNArchService_LoadProposed(t *testing.T) { + arch := service.NewNNArchService("../../web/nn_architectures/proposed.json") + require.NotNil(t, arch) + + groups := arch.GetFieldGroups() + assert.NotEmpty(t, groups) + + names := make([]string, 0, len(groups)) + for _, group := range groups { + names = append(names, group.Name) + assert.NotEmpty(t, group.FullName) + } + assert.Contains(t, names, "data") + assert.Contains(t, names, "model") + assert.Contains(t, names, "training") + assert.Contains(t, names, "validation") + + results := arch.GetExpectedResults() + assert.Contains(t, results.Onnx, "kaon.onnx") +} diff --git a/internal/service/training_dataset_random_selection.go b/internal/service/training_dataset_random_selection.go index 6315d58..0c7798a 100644 --- a/internal/service/training_dataset_random_selection.go +++ b/internal/service/training_dataset_random_selection.go @@ -29,7 +29,7 @@ func (s *TrainingDatasetService) SelectRandomAODSubset(path string, runCount, fi aods, err := s.JAliEn.FindAODFiles(path) if err != nil { - return nil, handleJAlienError(err) + return nil, mapJAlienError("FindAODFiles", err) } if len(aods) == 0 { return nil, &ErrHandlerValidation{ diff --git a/internal/service/training_dataset_service.go b/internal/service/training_dataset_service.go index 7457684..97568a1 100644 --- a/internal/service/training_dataset_service.go +++ b/internal/service/training_dataset_service.go @@ -23,17 +23,22 @@ type ITrainingDatasetService interface { type TrainingDatasetService struct { *repository.RepositoryContext - JAliEn IJAliEnService + JAliEn IJAliEnService + CCDBService ICCDBService + MonalisaService IMonalisaService } var errCCDBUnreachable = NewErrExternalServiceTimeout("CCDB") var errJAlienUnreachable = NewErrExternalServiceTimeout("JAlien") +var errMonalisaUnreachable = NewErrExternalServiceTimeout("Monalisa") var errDatasetNotFound = NewErrHandlerNotFound("TrainingDataset") -func NewTrainingDatasetService(repo *repository.RepositoryContext, jalien IJAliEnService) *TrainingDatasetService { +func NewTrainingDatasetService(repo *repository.RepositoryContext, jalien IJAliEnService, ccdbService ICCDBService, monalisaService IMonalisaService) *TrainingDatasetService { return &TrainingDatasetService{ RepositoryContext: repo, JAliEn: jalien, + CCDBService: ccdbService, + MonalisaService: monalisaService, } } @@ -63,7 +68,7 @@ func (s *TrainingDatasetService) GetByID(id uint) (*models.TrainingDataset, erro if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errDatasetNotFound } else { - return nil, errInternalServerError + return nil, InternalError("get training dataset by id", err) } } @@ -78,7 +83,18 @@ func (s *TrainingDatasetService) Create(td *models.TrainingDataset) error { } } - err := s.TrainingDataset.Create(td) + timestampFrom, timestampTo, err := resolveDatasetTimestampRange(td.AODFiles, s.MonalisaService, s.CCDBService) + if err != nil { + var external *ErrExternalServiceTimeout + if errors.As(err, &external) { + return err + } + return InternalError("resolve training dataset timestamp range", err) + } + td.AnchorProdTimestampFrom = timestampFrom + td.AnchorProdTimestampTo = timestampTo + + err = s.TrainingDataset.Create(td) if err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { @@ -87,7 +103,7 @@ func (s *TrainingDatasetService) Create(td *models.TrainingDataset) error { Msg: errMsgNotUnique, } } else { - return errInternalServerError + return InternalError("create training dataset", err) } } @@ -101,7 +117,7 @@ func (s *TrainingDatasetService) Delete(userId uint, id uint) error { if errors.Is(err, gorm.ErrRecordNotFound) { return errDatasetNotFound } else { - return errInternalServerError + return InternalError("delete training dataset", err) } } @@ -115,7 +131,7 @@ func (s *TrainingDatasetService) ExploreDirectory(path string) (*jalien.Director dirContents, err := s.JAliEn.ListAndParseDirectory(path) if err != nil { - return nil, "", handleJAlienError(err) + return nil, "", mapJAlienError("ListAndParseDirectory", err) } parentDir := "/" @@ -133,7 +149,7 @@ func (s *TrainingDatasetService) FindAods(path string) ([]jalien.AODFile, error) aodFiles, err := s.JAliEn.FindAODFiles(path) if err != nil { - return nil, handleJAlienError(err) + return nil, mapJAlienError("FindAODFiles", err) } return aodFiles, nil diff --git a/internal/service/training_dataset_timestamp_range.go b/internal/service/training_dataset_timestamp_range.go new file mode 100644 index 0000000..4138b03 --- /dev/null +++ b/internal/service/training_dataset_timestamp_range.go @@ -0,0 +1,128 @@ +package service + +import ( + "errors" + "fmt" + "time" + + "github.com/mytkom/AliceTraINT/internal/ccdb" + "github.com/mytkom/AliceTraINT/internal/jalien" +) + +func resolveDatasetTimestampRange(aodFiles []jalien.AODFile, monalisaService IMonalisaService, ccdbService ICCDBService) (time.Time, time.Time, error) { + if len(aodFiles) == 0 { + return time.Time{}, time.Time{}, errors.New("unexpected behaviour: empty training dataset") + } + + distinctPeriods := make(map[string]struct{}, len(aodFiles)) + for _, aod := range aodFiles { + if aod.LHCPeriod != "" { + distinctPeriods[aod.LHCPeriod] = struct{}{} + } + } + + if len(distinctPeriods) == 0 { + return time.Time{}, time.Time{}, errors.New("unexpected behaviour: empty training dataset periods") + } + + runList, err := monalisaService.GetRunList() + if err != nil { + return time.Time{}, time.Time{}, mapMonalisaError("GetRunList", err) + } + + globalMinRun, globalMaxRun, err := findGlobalRunRange(runList.TagToRuns, distinctPeriods) + if err != nil { + return time.Time{}, time.Time{}, err + } + + firstRunInfo, lastRunInfo, err := getRunInfoRangeFromCCDB(ccdbService, globalMinRun, globalMaxRun) + if err != nil { + return time.Time{}, time.Time{}, err + } + + return time.UnixMilli(int64(firstRunInfo.SOR)).UTC(), time.UnixMilli(int64(lastRunInfo.EOR)).UTC(), nil +} + +func findGlobalRunRange(runMap map[string][]uint64, periods map[string]struct{}) (uint64, uint64, error) { + var globalMinRun uint64 + var globalMaxRun uint64 + initialized := false + + for period := range periods { + runs, ok := runMap[period] + if !ok || len(runs) == 0 { + continue + } + + for _, run := range runs { + if !initialized || run < globalMinRun { + globalMinRun = run + } + if !initialized || run > globalMaxRun { + globalMaxRun = run + } + initialized = true + } + } + + if !initialized { + availablePeriods := make([]string, 0, len(runMap)) + for period := range runMap { + availablePeriods = append(availablePeriods, period) + } + return 0, 0, fmt.Errorf("no runs found for dataset periods in Monalisa run list; requested periods=%v; sample available periods=%v", + periodKeys(periods), sampleStrings(availablePeriods, 10)) + } + + return globalMinRun, globalMaxRun, nil +} + +func getRunInfoRangeFromCCDB(ccdbService ICCDBService, smallestRun, greatestRun uint64) (*ccdb.RunInformation, *ccdb.RunInformation, error) { + firstRunInfo, err := ccdbService.GetRunInformation(smallestRun) + if err != nil { + return nil, nil, mapCCDBError(fmt.Sprintf("GetRunInformation(%d)", smallestRun), err) + } + + lastRunInfo, err := ccdbService.GetRunInformation(greatestRun) + if err != nil { + return nil, nil, mapCCDBError(fmt.Sprintf("GetRunInformation(%d)", greatestRun), err) + } + + return firstRunInfo, lastRunInfo, nil +} + +func getDatasetAODRunRange(aodFiles []jalien.AODFile) (uint64, uint64, error) { + var minRun, maxRun uint64 + initialized := false + + for _, aod := range aodFiles { + if !initialized || aod.RunNumber < minRun { + minRun = aod.RunNumber + } + if !initialized || aod.RunNumber > maxRun { + maxRun = aod.RunNumber + } + initialized = true + } + + if !initialized { + return 0, 0, errors.New("unexpected behaviour: empty training dataset") + } + + return minRun, maxRun, nil +} + +func periodKeys(periods map[string]struct{}) []string { + keys := make([]string, 0, len(periods)) + for period := range periods { + keys = append(keys, period) + } + return keys +} + +func sampleStrings(values []string, limit int) []string { + if len(values) <= limit { + return values + } + return values[:limit] +} diff --git a/internal/service/training_machine_service.go b/internal/service/training_machine_service.go index b67de4d..ecd89d0 100644 --- a/internal/service/training_machine_service.go +++ b/internal/service/training_machine_service.go @@ -48,7 +48,7 @@ func (s *TrainingMachineService) Create(tm *models.TrainingMachine) (string, err Msg: errMsgNotUnique, } } else { - return "", errInternalServerError + return "", InternalError("create training machine", err) } } @@ -62,12 +62,12 @@ func (s *TrainingMachineService) GetAll(loggedUserId uint, userScoped bool) ([]m if userScoped { trainingMachines, err = s.TrainingMachine.GetAllUser(loggedUserId) if err != nil { - return nil, errInternalServerError + return nil, InternalError("get training machines", err) } } else { trainingMachines, err = s.TrainingMachine.GetAll() if err != nil { - return nil, errInternalServerError + return nil, InternalError("get training machines", err) } } @@ -80,7 +80,7 @@ func (s *TrainingMachineService) GetByID(id uint) (*models.TrainingMachine, erro if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errMachineNotFound } else { - return nil, errInternalServerError + return nil, InternalError("get training machine by id", err) } } @@ -93,7 +93,7 @@ func (s *TrainingMachineService) Delete(loggedUserId uint, id uint) error { if errors.Is(err, gorm.ErrRecordNotFound) { return errMachineNotFound } else { - return errInternalServerError + return InternalError("delete training machine", err) } } diff --git a/internal/service/training_task_service.go b/internal/service/training_task_service.go index 5017b65..58a1348 100644 --- a/internal/service/training_task_service.go +++ b/internal/service/training_task_service.go @@ -3,15 +3,10 @@ package service import ( "errors" "fmt" - "log" - "regexp" - "slices" - "strconv" - "github.com/mytkom/AliceTraINT/internal/ccdb" + "github.com/mytkom/AliceTraINT/internal/applog" "github.com/mytkom/AliceTraINT/internal/db/models" "github.com/mytkom/AliceTraINT/internal/db/repository" - "github.com/mytkom/AliceTraINT/internal/jalien" "gorm.io/gorm" ) @@ -24,7 +19,7 @@ type TrainingTaskWithResults struct { type TrainingTaskHelpers struct { TrainingDatasets []models.TrainingDataset - FieldConfigs NNFieldConfigs + FieldGroups []NNFieldGroup } type ITrainingTaskService interface { @@ -37,21 +32,19 @@ type ITrainingTaskService interface { type TrainingTaskService struct { *repository.RepositoryContext - CCDBService ICCDBService - JAliEnService IJAliEnService - FileService IFileService - NNArch INNArchService - PeriodRegex *regexp.Regexp + CCDBService ICCDBService + MonalisaService IMonalisaService + FileService IFileService + NNArch INNArchService } -func NewTrainingTaskService(repo *repository.RepositoryContext, ccdbService ICCDBService, jalienService IJAliEnService, fileService IFileService, nnArch INNArchService) *TrainingTaskService { +func NewTrainingTaskService(repo *repository.RepositoryContext, ccdbService ICCDBService, monalisaService IMonalisaService, fileService IFileService, nnArch INNArchService) *TrainingTaskService { return &TrainingTaskService{ RepositoryContext: repo, CCDBService: ccdbService, - JAliEnService: jalienService, + MonalisaService: monalisaService, FileService: fileService, NNArch: nnArch, - PeriodRegex: regexp.MustCompile(`(/alice/sim/\d{4}/LHC[a-z0-9A-Z\_].+(/\d+)?)/\d+/AOD/\d+`), } } @@ -69,7 +62,7 @@ func (s *TrainingTaskService) Create(tt *models.TrainingTask) error { Msg: errMsgNotUnique, } } else { - return errInternalServerError + return InternalError("create training task", err) } } @@ -83,12 +76,12 @@ func (s *TrainingTaskService) GetAll(loggedUserId uint, userScoped bool) ([]mode if userScoped { trainingTasks, err = s.TrainingTask.GetAllUser(loggedUserId) if err != nil { - return nil, errInternalServerError + return nil, InternalError("get training tasks", err) } } else { trainingTasks, err = s.TrainingTask.GetAll() if err != nil { - return nil, errInternalServerError + return nil, InternalError("get training tasks", err) } } @@ -98,12 +91,12 @@ func (s *TrainingTaskService) GetAll(loggedUserId uint, userScoped bool) ([]mode func (s *TrainingTaskService) GetHelpers(loggedUserId uint) (*TrainingTaskHelpers, error) { trainingDatasets, err := s.TrainingDataset.GetAllUser(loggedUserId) if err != nil { - return nil, errInternalServerError + return nil, InternalError("get training task helpers", err) } return &TrainingTaskHelpers{ TrainingDatasets: trainingDatasets, - FieldConfigs: s.NNArch.GetFieldConfigs(), + FieldGroups: s.NNArch.GetFieldGroups(), }, nil } @@ -113,7 +106,7 @@ func (s *TrainingTaskService) GetByID(id uint) (*TrainingTaskWithResults, error) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errTaskNotFound } else { - return nil, errInternalServerError + return nil, InternalError("get training task by id", err) } } @@ -121,7 +114,7 @@ func (s *TrainingTaskService) GetByID(id uint) (*TrainingTaskWithResults, error) if trainingTask.Status >= models.Training { imageFiles, err = s.TrainingTaskResult.GetByType(trainingTask.ID, models.Image) if err != nil { - return nil, errInternalServerError + return nil, InternalError("get training task image results", err) } } @@ -129,7 +122,7 @@ func (s *TrainingTaskService) GetByID(id uint) (*TrainingTaskWithResults, error) if trainingTask.Status >= models.Benchmarking { onnxFiles, err = s.TrainingTaskResult.GetByType(trainingTask.ID, models.Onnx) if err != nil { - return nil, errInternalServerError + return nil, InternalError("get training task onnx results", err) } } @@ -137,7 +130,7 @@ func (s *TrainingTaskService) GetByID(id uint) (*TrainingTaskWithResults, error) if trainingTask.Status != models.Queued { logFiles, err = s.TrainingTaskResult.GetByType(trainingTask.ID, models.Log) if err != nil { - return nil, errInternalServerError + return nil, InternalError("get training task log results", err) } } @@ -154,9 +147,8 @@ func (s *TrainingTaskService) UploadOnnxResults(id uint) error { if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errTaskNotFound - } else { - return errInternalServerError } + return InternalError("get training task for upload", err) } if trainingTask.Status < models.Completed { @@ -166,43 +158,11 @@ func (s *TrainingTaskService) UploadOnnxResults(id uint) error { } } - lhcPeriods, err := s.getLHCPeriods(trainingTask) + minSOR, maxEOR, err := s.getUploadTimestampRange(trainingTask) if err != nil { return err } - var minSOR, maxEOR uint64 - initialized := false - - for i, period := range lhcPeriods { - log.Printf("%d: Name=\"%s\" DirPath=\"%s\"", i, period.Name, period.DirPath) - - dirContents, err := s.JAliEnService.ListAndParseDirectory(period.DirPath) - if err != nil { - return err - } - - smallestRun, greatestRun, err := s.findRunNumberRange(dirContents.Subdirs) - if err != nil { - return err - } - - firstRunInfo, lastRunInfo, err := s.getRunInfoRange(smallestRun, greatestRun) - if err != nil { - return err - } - - if !initialized || firstRunInfo.SOR < minSOR { - minSOR = firstRunInfo.SOR - } - - if !initialized || lastRunInfo.EOR > maxEOR { - maxEOR = lastRunInfo.EOR - } - - initialized = true - } - mappedOnnxFiles, err := s.filterOnnxFiles(trainingTask.ID) if err != nil { return err @@ -216,104 +176,38 @@ func (s *TrainingTaskService) UploadOnnxResults(id uint) error { trainingTask.Status = models.Uploaded if err := s.TrainingTask.Update(trainingTask); err != nil { - return errInternalServerError + return InternalError("update training task status after upload", err) } return nil } -type lhcPeriod struct { - Name string - DirPath string -} - -func (s *TrainingTaskService) periodPathFromAODPath(aodPath string) (string, error) { - matches := s.PeriodRegex.FindStringSubmatch(aodPath) - - if len(matches) != 3 { - return "", errors.New("unexpected AOD path format, cannot correctly match") +func (s *TrainingTaskService) getUploadTimestampRange(trainingTask *models.TrainingTask) (uint64, uint64, error) { + if !trainingTask.TrainingDataset.AnchorProdTimestampFrom.IsZero() && + !trainingTask.TrainingDataset.AnchorProdTimestampTo.IsZero() { + return uint64(trainingTask.TrainingDataset.AnchorProdTimestampFrom.UnixMilli()), + uint64(trainingTask.TrainingDataset.AnchorProdTimestampTo.UnixMilli()), + nil } - return matches[1], nil -} - -func (s *TrainingTaskService) getLHCPeriods(task *models.TrainingTask) ([]lhcPeriod, error) { - var periods []lhcPeriod - initialized := false - - for _, aod := range task.TrainingDataset.AODFiles { - if !slices.ContainsFunc(periods, func(p lhcPeriod) bool { - return p.Name == aod.LHCPeriod - }) { - periodPath, err := s.periodPathFromAODPath(aod.Path) - if err != nil { - return nil, err - } - - periods = append(periods, lhcPeriod{ - Name: aod.LHCPeriod, - DirPath: periodPath, - }) - } - initialized = true - } - - if !initialized { - return nil, errors.New("unexpected behaviour: empty training dataset") + timestampFrom, timestampTo, err := resolveDatasetTimestampRange(trainingTask.TrainingDataset.AODFiles, s.MonalisaService, s.CCDBService) + if err == nil { + return uint64(timestampFrom.UnixMilli()), uint64(timestampTo.UnixMilli()), nil } - return periods, nil -} - -func (s *TrainingTaskService) findRunNumberRange(subdirs []jalien.Dir) (uint64, uint64, error) { - var smallestRun, greatestRun uint64 - initialized := false + applog.Fallback("resolve dataset timestamp range", err, "dataset AOD run range") - for _, dir := range subdirs { - log.Printf("Dir: %+v", dir) - - runNumber, err := strconv.ParseUint(dir.Name, 10, 64) - if err != nil { - log.Println(err.Error()) - continue - } - log.Printf("RunNumber: %+v", runNumber) - - if !initialized || runNumber < smallestRun { - smallestRun = runNumber - } - if !initialized || runNumber > greatestRun { - greatestRun = runNumber - } - initialized = true - } - - log.Printf("SmallestRun: %+v", smallestRun) - log.Printf("GreatestRun: %+v", greatestRun) - log.Printf("Initialized: %+v", initialized) - - if !initialized { - return 0, 0, errors.New("unexpected behaviour: empty training dataset") - } - - return smallestRun, greatestRun, nil -} - -func (s *TrainingTaskService) getRunInfoRange(smallestRun, greatestRun uint64) (*ccdb.RunInformation, *ccdb.RunInformation, error) { - firstRunInfo, err := s.CCDBService.GetRunInformation(smallestRun) + minRun, maxRun, err := getDatasetAODRunRange(trainingTask.TrainingDataset.AODFiles) if err != nil { - return nil, nil, handleCCDBError(err) + return 0, 0, err } - lastRunInfo, err := s.CCDBService.GetRunInformation(greatestRun) + firstRunInfo, lastRunInfo, err := getRunInfoRangeFromCCDB(s.CCDBService, minRun, maxRun) if err != nil { - return nil, nil, handleCCDBError(err) + return 0, 0, err } - log.Printf("From run %d, SOR %d", firstRunInfo.RunNumber, firstRunInfo.SOR) - log.Printf("to run %d, EOR %d", lastRunInfo.RunNumber, lastRunInfo.EOR) - - return firstRunInfo, lastRunInfo, nil + return firstRunInfo.SOR, lastRunInfo.EOR, nil } func (s *TrainingTaskService) filterOnnxFiles(ttId uint) (map[string]*models.TrainingTaskResult, error) { @@ -335,7 +229,6 @@ func (s *TrainingTaskService) filterOnnxFiles(ttId uint) (map[string]*models.Tra } if !found { - log.Printf("expected file not present: %s", localName) return nil, NewErrHandlerNotFound(fmt.Sprintf("TrainingTask's result file: %s", localName)) } } @@ -351,7 +244,7 @@ func (s *TrainingTaskService) uploadOnnxFile(sor, eor uint64, onnxFile *models.T defer closeFile(f) if err := s.CCDBService.UploadFile(sor, eor, uploadFilename, f); err != nil { - return handleCCDBError(err) + return mapCCDBError("UploadFile", err) } return nil diff --git a/internal/utils/config_json.go b/internal/utils/config_json.go new file mode 100644 index 0000000..400a1bf --- /dev/null +++ b/internal/utils/config_json.go @@ -0,0 +1,87 @@ +package utils + +import ( + "encoding/json" + "fmt" + "html/template" + "strings" +) + +const Null = "null" + +// ConfigJSON returns compact JSON for embedding in pages (copy / prefill). +func ConfigJSON(v any) template.JS { + normalized := normalizeJSONValue(v) + if normalized == nil { + return Null + } + b, err := json.Marshal(normalized) + if err != nil { + return Null + } + safe := strings.ReplaceAll(string(b), "<", `\u003c`) + return template.JS(safe) +} + +// FormatConfigPretty returns indented JSON for display in a code block. +func FormatConfigPretty(v any) string { + normalized := normalizeJSONValue(v) + if normalized == nil { + return "" + } + b, err := json.MarshalIndent(normalized, "", " ") + if err != nil { + return fmt.Sprint(v) + } + return string(b) +} + +// HasConfiguration reports whether a stored config value has usable content. +func HasConfiguration(v any) bool { + return normalizeJSONValue(v) != nil +} + +func normalizeJSONValue(v any) any { + if v == nil { + return nil + } + + switch typed := v.(type) { + case string: + if typed == "" { + return nil + } + var parsed any + if err := json.Unmarshal([]byte(typed), &parsed); err == nil { + return parsed + } + return typed + case []byte: + if len(typed) == 0 { + return nil + } + var parsed any + if err := json.Unmarshal(typed, &parsed); err == nil { + return parsed + } + return string(typed) + case map[string]any: + return typed + case map[any]any: + out := make(map[string]any, len(typed)) + for key, value := range typed { + out[fmt.Sprint(key)] = value + } + return out + default: + b, err := json.Marshal(typed) + if err != nil { + return typed + } + var parsed any + if err := json.Unmarshal(b, &parsed); err != nil { + return typed + } + return parsed + } +} diff --git a/internal/utils/config_json_test.go b/internal/utils/config_json_test.go new file mode 100644 index 0000000..78af730 --- /dev/null +++ b/internal/utils/config_json_test.go @@ -0,0 +1,34 @@ +package utils_test + +import ( + "testing" + + "github.com/mytkom/AliceTraINT/internal/utils" + "github.com/stretchr/testify/assert" +) + +func TestConfigJSON(t *testing.T) { + assert.Equal(t, `{"seed":1}`, string(utils.ConfigJSON(map[string]any{"seed": float64(1)}))) + assert.Equal(t, "null", string(utils.ConfigJSON(nil))) + assert.Equal(t, "null", string(utils.ConfigJSON(""))) +} + +func TestFormatConfigPretty(t *testing.T) { + pretty := utils.FormatConfigPretty(map[string]any{ + "seed": float64(1), + "model": map[string]any{ + "architecture": "attention", + }, + }) + assert.Contains(t, pretty, "\n") + assert.Contains(t, pretty, `"seed": 1`) + assert.Contains(t, pretty, `"architecture": "attention"`) + assert.Equal(t, "", utils.FormatConfigPretty(nil)) +} + +func TestHasConfiguration(t *testing.T) { + assert.False(t, utils.HasConfiguration(nil)) + assert.False(t, utils.HasConfiguration("")) + assert.True(t, utils.HasConfiguration(map[string]any{"bs": float64(1)})) + assert.True(t, utils.HasConfiguration(`{"bs":1}`)) +} diff --git a/internal/utils/template.go b/internal/utils/template.go index cd9c1c9..692ff5a 100644 --- a/internal/utils/template.go +++ b/internal/utils/template.go @@ -1,16 +1,95 @@ package utils -import "html/template" +import ( + "encoding/json" + "fmt" + "html/template" + "strings" + + "github.com/mytkom/AliceTraINT/internal/jalien" +) func BaseTemplate() *template.Template { return template.Must(template.New("").Funcs(template.FuncMap{ - "formatFileSizePretty": FormatSizePretty, - "isImage": IsImage, - "isText": IsText, - "safeHTML": SafeHTML, + "formatFileSizePretty": FormatSizePretty, + "isImage": IsImage, + "isText": IsText, + "safeHTML": SafeHTML, + "summarizeAODByPeriod": jalien.SummarizeByPeriod, + "summarizeAODByPeriodAndRun": jalien.SummarizeByPeriodAndRun, + "configFieldName": ConfigFieldName, + "configPath": ConfigPath, + "formatListDefault": FormatListDefault, + "rangeDefaultAt": RangeDefaultAt, + "showWhenEqualsJSON": ShowWhenEqualsJSON, + "optionSelected": OptionSelected, + "configJSON": ConfigJSON, + "formatConfigPretty": FormatConfigPretty, }).ParseGlob("web/templates/**/*.html")) } +func ConfigPath(path, name string) string { + if path == "" { + return name + } + return path + "." + name +} + +func ConfigFieldName(path, name string) string { + return "configuration." + ConfigPath(path, name) +} + +func FormatListDefault(v interface{}) string { + if v == nil { + return "" + } + switch vals := v.(type) { + case []interface{}: + parts := make([]string, 0, len(vals)) + for _, item := range vals { + parts = append(parts, fmt.Sprint(item)) + } + return strings.Join(parts, ", ") + case []string: + return strings.Join(vals, ", ") + default: + return fmt.Sprint(v) + } +} + +func RangeDefaultAt(v interface{}, index int) interface{} { + switch vals := v.(type) { + case []interface{}: + if index >= 0 && index < len(vals) { + return vals[index] + } + case []float64: + if index >= 0 && index < len(vals) { + return vals[index] + } + case []int: + if index >= 0 && index < len(vals) { + return vals[index] + } + } + return "" +} + +func ShowWhenEqualsJSON(v interface{}) template.JS { + b, err := json.Marshal(v) + if err != nil { + return "null" + } + return template.JS(b) +} + +func OptionSelected(optionValue, defaultValue interface{}) bool { + if optionValue == nil && defaultValue == nil { + return true + } + return fmt.Sprint(optionValue) == fmt.Sprint(defaultValue) +} + // SafeHTML marks a string as trusted HTML for use in templates. func SafeHTML(s string) template.HTML { return template.HTML(s) diff --git a/static/css/output.css b/static/css/output.css index 52004a3..bba9f23 100644 --- a/static/css/output.css +++ b/static/css/output.css @@ -1 +1 @@ -/*! tailwindcss v3.4.10 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]{display:none}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-radius:0;border-width:1px;font-size:1rem;line-height:1.5rem;padding:.5rem .75rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);border-color:#2563eb;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-bottom:0;padding-top:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;background-origin:border-box;border-color:#6b7280;border-width:1px;color:#2563eb;display:inline-block;flex-shrink:0;height:1rem;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle;width:1rem;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{background-color:currentColor;background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{background-color:currentColor;border-color:transparent}[type=checkbox]:indeterminate{background-color:currentColor;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{background-color:currentColor;border-color:transparent}[type=file]{background:unset;border-color:inherit;border-radius:0;border-width:0;font-size:unset;line-height:inherit;padding:0}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.prose{color:var(--tw-prose-body);max-width:65ch}.prose :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em;margin-top:1.25em}.prose :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-lead);font-size:1.25em;line-height:1.6;margin-bottom:1.2em;margin-top:1.2em}.prose :where(a):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-links);font-weight:500;text-decoration:underline}.prose :where(strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-bold);font-weight:600}.prose :where(a strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal;margin-bottom:1.25em;margin-top:1.25em;padding-inline-start:1.625em}.prose :where(ol[type=A]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=A s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=I]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type=I s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type="1"]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal}.prose :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:disc;margin-bottom:1.25em;margin-top:1.25em;padding-inline-start:1.625em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-counters);font-weight:400}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-bullets)}.prose :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.25em}.prose :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){border-color:var(--tw-prose-hr);border-top-width:1px;margin-bottom:3em;margin-top:3em}.prose :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){border-inline-start-color:var(--tw-prose-quote-borders);border-inline-start-width:.25rem;color:var(--tw-prose-quotes);font-style:italic;font-weight:500;margin-bottom:1.6em;margin-top:1.6em;padding-inline-start:1em;quotes:"\201C""\201D""\2018""\2019"}.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:open-quote}.prose :where(blockquote p:last-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:close-quote}.prose :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-size:2.25em;font-weight:800;line-height:1.1111111;margin-bottom:.8888889em;margin-top:0}.prose :where(h1 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:900}.prose :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-size:1.5em;font-weight:700;line-height:1.3333333;margin-bottom:1em;margin-top:2em}.prose :where(h2 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:800}.prose :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-size:1.25em;font-weight:600;line-height:1.6;margin-bottom:.6em;margin-top:1.6em}.prose :where(h3 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:700}.prose :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;line-height:1.5;margin-bottom:.5em;margin-top:1.5em}.prose :where(h4 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:700}.prose :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:2em;margin-top:2em}.prose :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){display:block;margin-bottom:2em;margin-top:2em}.prose :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:2em;margin-top:2em}.prose :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){border-radius:.3125rem;box-shadow:0 0 0 1px rgb(var(--tw-prose-kbd-shadows)/10%),0 3px 0 rgb(var(--tw-prose-kbd-shadows)/10%);color:var(--tw-prose-kbd);font-family:inherit;font-size:.875em;font-weight:500;padding-inline-end:.375em;padding-bottom:.1875em;padding-top:.1875em;padding-inline-start:.375em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-code);font-size:.875em;font-weight:600}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:"`"}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:"`"}.prose :where(a code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h1 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.875em}.prose :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.9em}.prose :where(h4 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--tw-prose-pre-bg);border-radius:.375rem;color:var(--tw-prose-pre-code);font-size:.875em;font-weight:400;line-height:1.7142857;margin-bottom:1.7142857em;margin-top:1.7142857em;overflow-x:auto;padding-inline-end:1.1428571em;padding-bottom:.8571429em;padding-top:.8571429em;padding-inline-start:1.1428571em}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;border-radius:0;border-width:0;color:inherit;font-family:inherit;font-size:inherit;font-weight:inherit;line-height:inherit;padding:0}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:none}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.prose :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.875em;line-height:1.7142857;margin-bottom:2em;margin-top:2em;table-layout:auto;width:100%}.prose :where(thead):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-color:var(--tw-prose-th-borders);border-bottom-width:1px}.prose :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em;vertical-align:bottom}.prose :where(tbody tr):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-color:var(--tw-prose-td-borders);border-bottom-width:1px}.prose :where(tbody tr:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:0}.prose :where(tbody td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:baseline}.prose :where(tfoot):not(:where([class~=not-prose],[class~=not-prose] *)){border-top-color:var(--tw-prose-th-borders);border-top-width:1px}.prose :where(tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:top}.prose :where(th,td):not(:where([class~=not-prose],[class~=not-prose] *)){text-align:start}.prose :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0;margin-top:0}.prose :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-captions);font-size:.875em;line-height:1.4285714;margin-top:.8571429em}.prose{--tw-prose-body:#374151;--tw-prose-headings:#111827;--tw-prose-lead:#4b5563;--tw-prose-links:#111827;--tw-prose-bold:#111827;--tw-prose-counters:#6b7280;--tw-prose-bullets:#d1d5db;--tw-prose-hr:#e5e7eb;--tw-prose-quotes:#111827;--tw-prose-quote-borders:#e5e7eb;--tw-prose-captions:#6b7280;--tw-prose-kbd:#111827;--tw-prose-kbd-shadows:17 24 39;--tw-prose-code:#111827;--tw-prose-pre-code:#e5e7eb;--tw-prose-pre-bg:#1f2937;--tw-prose-th-borders:#d1d5db;--tw-prose-td-borders:#e5e7eb;--tw-prose-invert-body:#d1d5db;--tw-prose-invert-headings:#fff;--tw-prose-invert-lead:#9ca3af;--tw-prose-invert-links:#fff;--tw-prose-invert-bold:#fff;--tw-prose-invert-counters:#9ca3af;--tw-prose-invert-bullets:#4b5563;--tw-prose-invert-hr:#374151;--tw-prose-invert-quotes:#f3f4f6;--tw-prose-invert-quote-borders:#374151;--tw-prose-invert-captions:#9ca3af;--tw-prose-invert-kbd:#fff;--tw-prose-invert-kbd-shadows:255 255 255;--tw-prose-invert-code:#fff;--tw-prose-invert-pre-code:#d1d5db;--tw-prose-invert-pre-bg:rgba(0,0,0,.5);--tw-prose-invert-th-borders:#4b5563;--tw-prose-invert-td-borders:#374151;font-size:1rem;line-height:1.75}.prose :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0;margin-top:0}.prose :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:.5em;margin-top:.5em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(.prose>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:.75em;margin-top:.75em}.prose :where(.prose>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(.prose>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:.75em;margin-top:.75em}.prose :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em;margin-top:1.25em}.prose :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;padding-inline-start:1.625em}.prose :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-top:.5714286em;padding-inline-start:.5714286em}.prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:2em;margin-top:2em}.prose :where(.prose>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(.prose>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.prose-lg{font-size:1.125rem;line-height:1.7777778}.prose-lg :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.3333333em;margin-top:1.3333333em}.prose-lg :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:1.2222222em;line-height:1.4545455;margin-bottom:1.0909091em;margin-top:1.0909091em}.prose-lg :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.6666667em;margin-top:1.6666667em;padding-inline-start:1em}.prose-lg :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:2.6666667em;line-height:1;margin-bottom:.8333333em;margin-top:0}.prose-lg :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:1.6666667em;line-height:1.3333333;margin-bottom:1.0666667em;margin-top:1.8666667em}.prose-lg :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:1.3333333em;line-height:1.5;margin-bottom:.6666667em;margin-top:1.6666667em}.prose-lg :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){line-height:1.5555556;margin-bottom:.4444444em;margin-top:1.7777778em}.prose-lg :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.7777778em;margin-top:1.7777778em}.prose-lg :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.7777778em;margin-top:1.7777778em}.prose-lg :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0;margin-top:0}.prose-lg :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.7777778em;margin-top:1.7777778em}.prose-lg :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){border-radius:.3125rem;font-size:.8888889em;padding-inline-end:.4444444em;padding-bottom:.2222222em;padding-top:.2222222em;padding-inline-start:.4444444em}.prose-lg :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.8888889em}.prose-lg :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.8666667em}.prose-lg :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.875em}.prose-lg :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){border-radius:.375rem;font-size:.8888889em;line-height:1.75;margin-bottom:2em;margin-top:2em;padding-inline-end:1.5em;padding-bottom:1em;padding-top:1em;padding-inline-start:1.5em}.prose-lg :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.3333333em;margin-top:1.3333333em;padding-inline-start:1.5555556em}.prose-lg :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.3333333em;margin-top:1.3333333em;padding-inline-start:1.5555556em}.prose-lg :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:.6666667em;margin-top:.6666667em}.prose-lg :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.4444444em}.prose-lg :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.4444444em}.prose-lg :where(.prose-lg>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:.8888889em;margin-top:.8888889em}.prose-lg :where(.prose-lg>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.3333333em}.prose-lg :where(.prose-lg>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.3333333em}.prose-lg :where(.prose-lg>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.3333333em}.prose-lg :where(.prose-lg>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.3333333em}.prose-lg :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:.8888889em;margin-top:.8888889em}.prose-lg :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.3333333em;margin-top:1.3333333em}.prose-lg :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.3333333em}.prose-lg :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.6666667em;padding-inline-start:1.5555556em}.prose-lg :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:3.1111111em;margin-top:3.1111111em}.prose-lg :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose-lg :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose-lg :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose-lg :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose-lg :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.8888889em;line-height:1.5}.prose-lg :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:.75em;padding-bottom:.75em;padding-inline-start:.75em}.prose-lg :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose-lg :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose-lg :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:.75em;padding-bottom:.75em;padding-top:.75em;padding-inline-start:.75em}.prose-lg :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose-lg :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose-lg :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.7777778em;margin-top:1.7777778em}.prose-lg :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0;margin-top:0}.prose-lg :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.8888889em;line-height:1.5;margin-top:1em}.prose-lg :where(.prose-lg>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose-lg :where(.prose-lg>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.prose-sky{--tw-prose-links:#0284c7;--tw-prose-invert-links:#0ea5e9}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.left-4{left:1rem}.right-4{right:1rem}.top-0{top:0}.top-1\/2{top:50%}.z-10{z-index:10}.col-span-1{grid-column:span 1/span 1}.col-span-2{grid-column:span 2/span 2}.col-start-1{grid-column-start:1}.col-start-2{grid-column-start:2}.row-start-2{grid-row-start:2}.mx-auto{margin-left:auto;margin-right:auto}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-4{margin-bottom:1rem;margin-top:1rem}.mb-2{margin-bottom:.5rem}.mb-5{margin-bottom:1.25rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.box-border{box-sizing:border-box}.block{display:block}.flex{display:flex}.table{display:table}.grid{display:grid}.hidden{display:none}.size-14{height:3.5rem;width:3.5rem}.size-4{height:1rem;width:1rem}.h-12{height:3rem}.h-3{height:.75rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-full{height:100%}.max-h-96{max-height:24rem}.max-h-full{max-height:100%}.max-h-screen{max-height:100vh}.min-h-48{min-height:12rem}.min-h-96{min-height:24rem}.min-h-screen{min-height:100vh}.w-16{width:4rem}.w-3{width:.75rem}.w-40{width:10rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-full{width:100%}.min-w-0{min-width:0}.max-w-full{max-width:100%}.flex-1{flex:1 1 0%}.flex-shrink{flex-shrink:1}.flex-shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.border-collapse{border-collapse:collapse}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-y-1\/2,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.auto-cols-auto{grid-auto-columns:auto}.auto-rows-auto{grid-auto-rows:auto}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-rows-2{grid-template-rows:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.content-stretch{align-content:stretch}.items-start{align-items:flex-start}.items-center{align-items:center}.items-stretch{align-items:stretch}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.justify-stretch{justify-content:stretch}.gap-0{gap:0}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-8{gap:2rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.self-end{align-self:flex-end}.self-center{align-self:center}.self-stretch{align-self:stretch}.justify-self-start{justify-self:start}.justify-self-end{justify-self:end}.justify-self-stretch{justify-self:stretch}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-4{border-left-width:4px}.border-dotted{border-style:dotted}.border-sky-200{--tw-border-opacity:1;border-color:rgb(186 230 253/var(--tw-border-opacity))}.border-sky-500{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity))}.border-sky-900{--tw-border-opacity:1;border-color:rgb(12 74 110/var(--tw-border-opacity))}.border-transparent{border-color:transparent}.bg-emerald-600{--tw-bg-opacity:1;background-color:rgb(5 150 105/var(--tw-bg-opacity))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity))}.bg-green-400{--tw-bg-opacity:1;background-color:rgb(74 222 128/var(--tw-bg-opacity))}.bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity))}.bg-red-400{--tw-bg-opacity:1;background-color:rgb(248 113 113/var(--tw-bg-opacity))}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity))}.bg-sky-100{--tw-bg-opacity:1;background-color:rgb(224 242 254/var(--tw-bg-opacity))}.bg-sky-200{--tw-bg-opacity:1;background-color:rgb(186 230 253/var(--tw-bg-opacity))}.bg-sky-50{--tw-bg-opacity:1;background-color:rgb(240 249 255/var(--tw-bg-opacity))}.bg-sky-800{--tw-bg-opacity:1;background-color:rgb(7 89 133/var(--tw-bg-opacity))}.bg-sky-900{--tw-bg-opacity:1;background-color:rgb(12 74 110/var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.bg-yellow-200{--tw-bg-opacity:1;background-color:rgb(254 240 138/var(--tw-bg-opacity))}.bg-yellow-600{--tw-bg-opacity:1;background-color:rgb(202 138 4/var(--tw-bg-opacity))}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-normal{line-height:1.5}.tracking-wide{letter-spacing:.025em}.text-gray-50{--tw-text-opacity:1;color:rgb(249 250 251/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity))}.text-sky-50{--tw-text-opacity:1;color:rgb(240 249 255/var(--tw-text-opacity))}.text-sky-900{--tw-text-opacity:1;color:rgb(12 74 110/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.underline{text-decoration-line:underline}.opacity-50{opacity:.5}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.display-htmx-indicator{display:none}.htmx-request .display-htmx-indicator,.htmx-request.display-htmx-indicator{display:inline}@media (prefers-color-scheme:dark){.dark\:prose-invert{--tw-prose-body:var(--tw-prose-invert-body);--tw-prose-headings:var(--tw-prose-invert-headings);--tw-prose-lead:var(--tw-prose-invert-lead);--tw-prose-links:var(--tw-prose-invert-links);--tw-prose-bold:var(--tw-prose-invert-bold);--tw-prose-counters:var(--tw-prose-invert-counters);--tw-prose-bullets:var(--tw-prose-invert-bullets);--tw-prose-hr:var(--tw-prose-invert-hr);--tw-prose-quotes:var(--tw-prose-invert-quotes);--tw-prose-quote-borders:var(--tw-prose-invert-quote-borders);--tw-prose-captions:var(--tw-prose-invert-captions);--tw-prose-kbd:var(--tw-prose-invert-kbd);--tw-prose-kbd-shadows:var(--tw-prose-invert-kbd-shadows);--tw-prose-code:var(--tw-prose-invert-code);--tw-prose-pre-code:var(--tw-prose-invert-pre-code);--tw-prose-pre-bg:var(--tw-prose-invert-pre-bg);--tw-prose-th-borders:var(--tw-prose-invert-th-borders);--tw-prose-td-borders:var(--tw-prose-invert-td-borders)}}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:border-sky-50:hover{--tw-border-opacity:1;border-color:rgb(240 249 255/var(--tw-border-opacity))}.hover\:bg-gray-600:hover{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity))}.hover\:bg-red-400:hover{--tw-bg-opacity:1;background-color:rgb(248 113 113/var(--tw-bg-opacity))}.hover\:bg-sky-100:hover{--tw-bg-opacity:1;background-color:rgb(224 242 254/var(--tw-bg-opacity))}.hover\:bg-sky-200:hover{--tw-bg-opacity:1;background-color:rgb(186 230 253/var(--tw-bg-opacity))}.hover\:bg-sky-700:hover{--tw-bg-opacity:1;background-color:rgb(3 105 161/var(--tw-bg-opacity))}.hover\:bg-sky-800:hover{--tw-bg-opacity:1;background-color:rgb(7 89 133/var(--tw-bg-opacity))}.hover\:bg-white:hover{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.hover\:text-sky-700:hover{--tw-text-opacity:1;color:rgb(3 105 161/var(--tw-text-opacity))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.group:hover .group-hover\:border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity))}.group:hover .group-hover\:text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity))}.prose-img\:mx-auto :is(:where(img):not(:where([class~=not-prose],[class~=not-prose] *))){margin-left:auto;margin-right:auto}@media (min-width:640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:768px){.md\:sticky{position:sticky}.md\:top-4{top:1rem}.md\:col-start-4{grid-column-start:4}.md\:flex{display:flex}.md\:grid{display:grid}.md\:hidden{display:none}.md\:w-64{width:16rem}.md\:max-w-5xl{max-width:64rem}.md\:auto-rows-auto{grid-auto-rows:auto}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:justify-between{justify-content:space-between}.md\:self-auto{align-self:auto}.md\:justify-self-end{justify-self:end}.md\:text-lg{font-size:1.125rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:col-start-auto{grid-column-start:auto}.lg\:size-20{height:5rem;width:5rem}.lg\:size-5{height:1.25rem;width:1.25rem}.lg\:size-6{height:1.5rem;width:1.5rem}.lg\:w-1\/2{width:50%}.lg\:w-72{width:18rem}.lg\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:justify-self-end{justify-self:end}.lg\:px-5{padding-left:1.25rem;padding-right:1.25rem}.lg\:text-right{text-align:right}.lg\:text-lg{font-size:1.125rem;line-height:1.75rem}}@media (min-width:1280px){.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:text-lg{font-size:1.125rem;line-height:1.75rem}}@media (prefers-color-scheme:dark){.dark\:border-sky-700{--tw-border-opacity:1;border-color:rgb(3 105 161/var(--tw-border-opacity))}.dark\:border-sky-800{--tw-border-opacity:1;border-color:rgb(7 89 133/var(--tw-border-opacity))}.dark\:bg-sky-600{--tw-bg-opacity:1;background-color:rgb(2 132 199/var(--tw-bg-opacity))}.dark\:bg-sky-700{--tw-bg-opacity:1;background-color:rgb(3 105 161/var(--tw-bg-opacity))}.dark\:bg-sky-800{--tw-bg-opacity:1;background-color:rgb(7 89 133/var(--tw-bg-opacity))}.dark\:bg-sky-900{--tw-bg-opacity:1;background-color:rgb(12 74 110/var(--tw-bg-opacity))}.dark\:bg-sky-950{--tw-bg-opacity:1;background-color:rgb(8 47 73/var(--tw-bg-opacity))}.dark\:text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity))}.dark\:text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.dark\:text-gray-50{--tw-text-opacity:1;color:rgb(249 250 251/var(--tw-text-opacity))}.dark\:text-sky-100{--tw-text-opacity:1;color:rgb(224 242 254/var(--tw-text-opacity))}.dark\:text-sky-200{--tw-text-opacity:1;color:rgb(186 230 253/var(--tw-text-opacity))}.dark\:text-sky-50{--tw-text-opacity:1;color:rgb(240 249 255/var(--tw-text-opacity))}.dark\:shadow-none{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.dark\:hover\:border-sky-50:hover{--tw-border-opacity:1;border-color:rgb(240 249 255/var(--tw-border-opacity))}.dark\:hover\:bg-sky-700:hover{--tw-bg-opacity:1;background-color:rgb(3 105 161/var(--tw-bg-opacity))}.dark\:hover\:bg-sky-800:hover{--tw-bg-opacity:1;background-color:rgb(7 89 133/var(--tw-bg-opacity))}.dark\:hover\:text-sky-100:hover{--tw-text-opacity:1;color:rgb(224 242 254/var(--tw-text-opacity))}} \ No newline at end of file +/*! tailwindcss v3.4.10 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]{display:none}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-radius:0;border-width:1px;font-size:1rem;line-height:1.5rem;padding:.5rem .75rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);border-color:#2563eb;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-bottom:0;padding-top:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;background-origin:border-box;border-color:#6b7280;border-width:1px;color:#2563eb;display:inline-block;flex-shrink:0;height:1rem;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle;width:1rem;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{background-color:currentColor;background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{background-color:currentColor;border-color:transparent}[type=checkbox]:indeterminate{background-color:currentColor;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{background-color:currentColor;border-color:transparent}[type=file]{background:unset;border-color:inherit;border-radius:0;border-width:0;font-size:unset;line-height:inherit;padding:0}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.prose{color:var(--tw-prose-body);max-width:65ch}.prose :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em;margin-top:1.25em}.prose :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-lead);font-size:1.25em;line-height:1.6;margin-bottom:1.2em;margin-top:1.2em}.prose :where(a):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-links);font-weight:500;text-decoration:underline}.prose :where(strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-bold);font-weight:600}.prose :where(a strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal;margin-bottom:1.25em;margin-top:1.25em;padding-inline-start:1.625em}.prose :where(ol[type=A]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=A s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=I]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type=I s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type="1"]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal}.prose :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:disc;margin-bottom:1.25em;margin-top:1.25em;padding-inline-start:1.625em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-counters);font-weight:400}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-bullets)}.prose :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;margin-top:1.25em}.prose :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){border-color:var(--tw-prose-hr);border-top-width:1px;margin-bottom:3em;margin-top:3em}.prose :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){border-inline-start-color:var(--tw-prose-quote-borders);border-inline-start-width:.25rem;color:var(--tw-prose-quotes);font-style:italic;font-weight:500;margin-bottom:1.6em;margin-top:1.6em;padding-inline-start:1em;quotes:"\201C""\201D""\2018""\2019"}.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:open-quote}.prose :where(blockquote p:last-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:close-quote}.prose :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-size:2.25em;font-weight:800;line-height:1.1111111;margin-bottom:.8888889em;margin-top:0}.prose :where(h1 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:900}.prose :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-size:1.5em;font-weight:700;line-height:1.3333333;margin-bottom:1em;margin-top:2em}.prose :where(h2 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:800}.prose :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-size:1.25em;font-weight:600;line-height:1.6;margin-bottom:.6em;margin-top:1.6em}.prose :where(h3 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:700}.prose :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;line-height:1.5;margin-bottom:.5em;margin-top:1.5em}.prose :where(h4 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:700}.prose :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:2em;margin-top:2em}.prose :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){display:block;margin-bottom:2em;margin-top:2em}.prose :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:2em;margin-top:2em}.prose :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){border-radius:.3125rem;box-shadow:0 0 0 1px rgb(var(--tw-prose-kbd-shadows)/10%),0 3px 0 rgb(var(--tw-prose-kbd-shadows)/10%);color:var(--tw-prose-kbd);font-family:inherit;font-size:.875em;font-weight:500;padding-inline-end:.375em;padding-bottom:.1875em;padding-top:.1875em;padding-inline-start:.375em}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-code);font-size:.875em;font-weight:600}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:"`"}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:"`"}.prose :where(a code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h1 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.875em}.prose :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.9em}.prose :where(h4 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(blockquote code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(thead th code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--tw-prose-pre-bg);border-radius:.375rem;color:var(--tw-prose-pre-code);font-size:.875em;font-weight:400;line-height:1.7142857;margin-bottom:1.7142857em;margin-top:1.7142857em;overflow-x:auto;padding-inline-end:1.1428571em;padding-bottom:.8571429em;padding-top:.8571429em;padding-inline-start:1.1428571em}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:transparent;border-radius:0;border-width:0;color:inherit;font-family:inherit;font-size:inherit;font-weight:inherit;line-height:inherit;padding:0}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:none}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.prose :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.875em;line-height:1.7142857;margin-bottom:2em;margin-top:2em;table-layout:auto;width:100%}.prose :where(thead):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-color:var(--tw-prose-th-borders);border-bottom-width:1px}.prose :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-inline-start:.5714286em;vertical-align:bottom}.prose :where(tbody tr):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-color:var(--tw-prose-td-borders);border-bottom-width:1px}.prose :where(tbody tr:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:0}.prose :where(tbody td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:baseline}.prose :where(tfoot):not(:where([class~=not-prose],[class~=not-prose] *)){border-top-color:var(--tw-prose-th-borders);border-top-width:1px}.prose :where(tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:top}.prose :where(th,td):not(:where([class~=not-prose],[class~=not-prose] *)){text-align:start}.prose :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0;margin-top:0}.prose :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-captions);font-size:.875em;line-height:1.4285714;margin-top:.8571429em}.prose{--tw-prose-body:#374151;--tw-prose-headings:#111827;--tw-prose-lead:#4b5563;--tw-prose-links:#111827;--tw-prose-bold:#111827;--tw-prose-counters:#6b7280;--tw-prose-bullets:#d1d5db;--tw-prose-hr:#e5e7eb;--tw-prose-quotes:#111827;--tw-prose-quote-borders:#e5e7eb;--tw-prose-captions:#6b7280;--tw-prose-kbd:#111827;--tw-prose-kbd-shadows:17 24 39;--tw-prose-code:#111827;--tw-prose-pre-code:#e5e7eb;--tw-prose-pre-bg:#1f2937;--tw-prose-th-borders:#d1d5db;--tw-prose-td-borders:#e5e7eb;--tw-prose-invert-body:#d1d5db;--tw-prose-invert-headings:#fff;--tw-prose-invert-lead:#9ca3af;--tw-prose-invert-links:#fff;--tw-prose-invert-bold:#fff;--tw-prose-invert-counters:#9ca3af;--tw-prose-invert-bullets:#4b5563;--tw-prose-invert-hr:#374151;--tw-prose-invert-quotes:#f3f4f6;--tw-prose-invert-quote-borders:#374151;--tw-prose-invert-captions:#9ca3af;--tw-prose-invert-kbd:#fff;--tw-prose-invert-kbd-shadows:255 255 255;--tw-prose-invert-code:#fff;--tw-prose-invert-pre-code:#d1d5db;--tw-prose-invert-pre-bg:rgba(0,0,0,.5);--tw-prose-invert-th-borders:#4b5563;--tw-prose-invert-td-borders:#374151;font-size:1rem;line-height:1.75}.prose :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0;margin-top:0}.prose :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:.5em;margin-top:.5em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(.prose>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:.75em;margin-top:.75em}.prose :where(.prose>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(.prose>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:.75em;margin-top:.75em}.prose :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em;margin-top:1.25em}.prose :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;padding-inline-start:1.625em}.prose :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:.5714286em;padding-bottom:.5714286em;padding-top:.5714286em;padding-inline-start:.5714286em}.prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:2em;margin-top:2em}.prose :where(.prose>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(.prose>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.prose-lg{font-size:1.125rem;line-height:1.7777778}.prose-lg :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.3333333em;margin-top:1.3333333em}.prose-lg :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:1.2222222em;line-height:1.4545455;margin-bottom:1.0909091em;margin-top:1.0909091em}.prose-lg :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.6666667em;margin-top:1.6666667em;padding-inline-start:1em}.prose-lg :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:2.6666667em;line-height:1;margin-bottom:.8333333em;margin-top:0}.prose-lg :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:1.6666667em;line-height:1.3333333;margin-bottom:1.0666667em;margin-top:1.8666667em}.prose-lg :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:1.3333333em;line-height:1.5;margin-bottom:.6666667em;margin-top:1.6666667em}.prose-lg :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){line-height:1.5555556;margin-bottom:.4444444em;margin-top:1.7777778em}.prose-lg :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.7777778em;margin-top:1.7777778em}.prose-lg :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.7777778em;margin-top:1.7777778em}.prose-lg :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0;margin-top:0}.prose-lg :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.7777778em;margin-top:1.7777778em}.prose-lg :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){border-radius:.3125rem;font-size:.8888889em;padding-inline-end:.4444444em;padding-bottom:.2222222em;padding-top:.2222222em;padding-inline-start:.4444444em}.prose-lg :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.8888889em}.prose-lg :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.8666667em}.prose-lg :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.875em}.prose-lg :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){border-radius:.375rem;font-size:.8888889em;line-height:1.75;margin-bottom:2em;margin-top:2em;padding-inline-end:1.5em;padding-bottom:1em;padding-top:1em;padding-inline-start:1.5em}.prose-lg :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.3333333em;margin-top:1.3333333em;padding-inline-start:1.5555556em}.prose-lg :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.3333333em;margin-top:1.3333333em;padding-inline-start:1.5555556em}.prose-lg :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:.6666667em;margin-top:.6666667em}.prose-lg :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.4444444em}.prose-lg :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.4444444em}.prose-lg :where(.prose-lg>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:.8888889em;margin-top:.8888889em}.prose-lg :where(.prose-lg>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.3333333em}.prose-lg :where(.prose-lg>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.3333333em}.prose-lg :where(.prose-lg>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.3333333em}.prose-lg :where(.prose-lg>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.3333333em}.prose-lg :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:.8888889em;margin-top:.8888889em}.prose-lg :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.3333333em;margin-top:1.3333333em}.prose-lg :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.3333333em}.prose-lg :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.6666667em;padding-inline-start:1.5555556em}.prose-lg :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:3.1111111em;margin-top:3.1111111em}.prose-lg :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose-lg :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose-lg :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose-lg :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose-lg :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.8888889em;line-height:1.5}.prose-lg :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:.75em;padding-bottom:.75em;padding-inline-start:.75em}.prose-lg :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose-lg :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose-lg :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:.75em;padding-bottom:.75em;padding-top:.75em;padding-inline-start:.75em}.prose-lg :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose-lg :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose-lg :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.7777778em;margin-top:1.7777778em}.prose-lg :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0;margin-top:0}.prose-lg :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.8888889em;line-height:1.5;margin-top:1em}.prose-lg :where(.prose-lg>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose-lg :where(.prose-lg>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.prose-sky{--tw-prose-links:#0284c7;--tw-prose-invert-links:#0ea5e9}.pointer-events-none{pointer-events:none}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.left-4{left:1rem}.right-4{right:1rem}.top-0{top:0}.top-1\/2{top:50%}.z-10{z-index:10}.col-span-1{grid-column:span 1/span 1}.col-span-2{grid-column:span 2/span 2}.col-start-1{grid-column-start:1}.col-start-2{grid-column-start:2}.row-start-2{grid-row-start:2}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-4{margin-bottom:1rem;margin-top:1rem}.mb-2{margin-bottom:.5rem}.mb-5{margin-bottom:1.25rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.box-border{box-sizing:border-box}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.size-14{height:3.5rem;width:3.5rem}.size-4{height:1rem;width:1rem}.h-12{height:3rem}.h-3{height:.75rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-full{height:100%}.max-h-80{max-height:20rem}.max-h-96{max-height:24rem}.max-h-full{max-height:100%}.max-h-screen{max-height:100vh}.min-h-0{min-height:0}.min-h-48{min-height:12rem}.min-h-96{min-height:24rem}.min-h-screen{min-height:100vh}.w-16{width:4rem}.w-28{width:7rem}.w-3{width:.75rem}.w-40{width:10rem}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-8{width:2rem}.w-full{width:100%}.min-w-0{min-width:0}.max-w-5xl{max-width:64rem}.max-w-full{max-width:100%}.flex-1{flex:1 1 0%}.flex-shrink{flex-shrink:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.border-collapse{border-collapse:collapse}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-y-1\/2,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.auto-cols-auto{grid-auto-columns:auto}.auto-rows-auto{grid-auto-rows:auto}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-rows-2{grid-template-rows:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.content-stretch{align-content:stretch}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-stretch{align-items:stretch}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.justify-stretch{justify-content:stretch}.gap-0{gap:0}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-8{gap:2rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.self-start{align-self:flex-start}.self-end{align-self:flex-end}.self-center{align-self:center}.self-stretch{align-self:stretch}.justify-self-start{justify-self:start}.justify-self-end{justify-self:end}.justify-self-stretch{justify-self:stretch}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-4{border-left-width:4px}.border-dotted{border-style:dotted}.border-sky-200{--tw-border-opacity:1;border-color:rgb(186 230 253/var(--tw-border-opacity))}.border-sky-300{--tw-border-opacity:1;border-color:rgb(125 211 252/var(--tw-border-opacity))}.border-sky-500{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity))}.border-sky-900{--tw-border-opacity:1;border-color:rgb(12 74 110/var(--tw-border-opacity))}.border-transparent{border-color:transparent}.bg-emerald-600{--tw-bg-opacity:1;background-color:rgb(5 150 105/var(--tw-bg-opacity))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity))}.bg-green-400{--tw-bg-opacity:1;background-color:rgb(74 222 128/var(--tw-bg-opacity))}.bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity))}.bg-red-400{--tw-bg-opacity:1;background-color:rgb(248 113 113/var(--tw-bg-opacity))}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity))}.bg-sky-100{--tw-bg-opacity:1;background-color:rgb(224 242 254/var(--tw-bg-opacity))}.bg-sky-200{--tw-bg-opacity:1;background-color:rgb(186 230 253/var(--tw-bg-opacity))}.bg-sky-50{--tw-bg-opacity:1;background-color:rgb(240 249 255/var(--tw-bg-opacity))}.bg-sky-800{--tw-bg-opacity:1;background-color:rgb(7 89 133/var(--tw-bg-opacity))}.bg-sky-900{--tw-bg-opacity:1;background-color:rgb(12 74 110/var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.bg-yellow-200{--tw-bg-opacity:1;background-color:rgb(254 240 138/var(--tw-bg-opacity))}.bg-yellow-600{--tw-bg-opacity:1;background-color:rgb(202 138 4/var(--tw-bg-opacity))}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-1\.5{padding-bottom:.375rem;padding-top:.375rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.pb-2{padding-bottom:.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-normal{line-height:1.5}.leading-relaxed{line-height:1.625}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.text-gray-50{--tw-text-opacity:1;color:rgb(249 250 251/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity))}.text-sky-50{--tw-text-opacity:1;color:rgb(240 249 255/var(--tw-text-opacity))}.text-sky-900{--tw-text-opacity:1;color:rgb(12 74 110/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.underline{text-decoration-line:underline}.opacity-50{opacity:.5}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.display-htmx-indicator{display:none}.htmx-request .display-htmx-indicator,.htmx-request.display-htmx-indicator{display:inline}@media (prefers-color-scheme:dark){.dark\:prose-invert{--tw-prose-body:var(--tw-prose-invert-body);--tw-prose-headings:var(--tw-prose-invert-headings);--tw-prose-lead:var(--tw-prose-invert-lead);--tw-prose-links:var(--tw-prose-invert-links);--tw-prose-bold:var(--tw-prose-invert-bold);--tw-prose-counters:var(--tw-prose-invert-counters);--tw-prose-bullets:var(--tw-prose-invert-bullets);--tw-prose-hr:var(--tw-prose-invert-hr);--tw-prose-quotes:var(--tw-prose-invert-quotes);--tw-prose-quote-borders:var(--tw-prose-invert-quote-borders);--tw-prose-captions:var(--tw-prose-invert-captions);--tw-prose-kbd:var(--tw-prose-invert-kbd);--tw-prose-kbd-shadows:var(--tw-prose-invert-kbd-shadows);--tw-prose-code:var(--tw-prose-invert-code);--tw-prose-pre-code:var(--tw-prose-invert-pre-code);--tw-prose-pre-bg:var(--tw-prose-invert-pre-bg);--tw-prose-th-borders:var(--tw-prose-invert-th-borders);--tw-prose-td-borders:var(--tw-prose-invert-td-borders)}}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:border-sky-50:hover{--tw-border-opacity:1;border-color:rgb(240 249 255/var(--tw-border-opacity))}.hover\:bg-gray-600:hover{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity))}.hover\:bg-red-400:hover{--tw-bg-opacity:1;background-color:rgb(248 113 113/var(--tw-bg-opacity))}.hover\:bg-sky-100:hover{--tw-bg-opacity:1;background-color:rgb(224 242 254/var(--tw-bg-opacity))}.hover\:bg-sky-200:hover{--tw-bg-opacity:1;background-color:rgb(186 230 253/var(--tw-bg-opacity))}.hover\:bg-sky-700:hover{--tw-bg-opacity:1;background-color:rgb(3 105 161/var(--tw-bg-opacity))}.hover\:bg-sky-800:hover{--tw-bg-opacity:1;background-color:rgb(7 89 133/var(--tw-bg-opacity))}.hover\:bg-white:hover{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.hover\:text-sky-700:hover{--tw-text-opacity:1;color:rgb(3 105 161/var(--tw-text-opacity))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.group:hover .group-hover\:border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity))}.group:hover .group-hover\:text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity))}.prose-img\:mx-auto :is(:where(img):not(:where([class~=not-prose],[class~=not-prose] *))){margin-left:auto;margin-right:auto}@media (min-width:640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:768px){.md\:sticky{position:sticky}.md\:top-4{top:1rem}.md\:col-start-4{grid-column-start:4}.md\:flex{display:flex}.md\:grid{display:grid}.md\:hidden{display:none}.md\:w-64{width:16rem}.md\:max-w-5xl{max-width:64rem}.md\:auto-rows-auto{grid-auto-rows:auto}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:justify-between{justify-content:space-between}.md\:self-auto{align-self:auto}.md\:justify-self-end{justify-self:end}.md\:text-lg{font-size:1.125rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:col-start-auto{grid-column-start:auto}.lg\:size-20{height:5rem;width:5rem}.lg\:size-5{height:1.25rem;width:1.25rem}.lg\:size-6{height:1.5rem;width:1.5rem}.lg\:w-1\/2{width:50%}.lg\:w-72{width:18rem}.lg\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:justify-self-end{justify-self:end}.lg\:px-5{padding-left:1.25rem;padding-right:1.25rem}.lg\:text-right{text-align:right}.lg\:text-lg{font-size:1.125rem;line-height:1.75rem}}@media (min-width:1280px){.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:text-lg{font-size:1.125rem;line-height:1.75rem}}@media (prefers-color-scheme:dark){.dark\:border-sky-600{--tw-border-opacity:1;border-color:rgb(2 132 199/var(--tw-border-opacity))}.dark\:border-sky-700{--tw-border-opacity:1;border-color:rgb(3 105 161/var(--tw-border-opacity))}.dark\:border-sky-800{--tw-border-opacity:1;border-color:rgb(7 89 133/var(--tw-border-opacity))}.dark\:bg-sky-600{--tw-bg-opacity:1;background-color:rgb(2 132 199/var(--tw-bg-opacity))}.dark\:bg-sky-700{--tw-bg-opacity:1;background-color:rgb(3 105 161/var(--tw-bg-opacity))}.dark\:bg-sky-800{--tw-bg-opacity:1;background-color:rgb(7 89 133/var(--tw-bg-opacity))}.dark\:bg-sky-900{--tw-bg-opacity:1;background-color:rgb(12 74 110/var(--tw-bg-opacity))}.dark\:bg-sky-950{--tw-bg-opacity:1;background-color:rgb(8 47 73/var(--tw-bg-opacity))}.dark\:text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity))}.dark\:text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity))}.dark\:text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.dark\:text-gray-50{--tw-text-opacity:1;color:rgb(249 250 251/var(--tw-text-opacity))}.dark\:text-sky-100{--tw-text-opacity:1;color:rgb(224 242 254/var(--tw-text-opacity))}.dark\:text-sky-200{--tw-text-opacity:1;color:rgb(186 230 253/var(--tw-text-opacity))}.dark\:text-sky-50{--tw-text-opacity:1;color:rgb(240 249 255/var(--tw-text-opacity))}.dark\:shadow-none{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.dark\:hover\:border-sky-50:hover{--tw-border-opacity:1;border-color:rgb(240 249 255/var(--tw-border-opacity))}.dark\:hover\:bg-sky-700:hover{--tw-bg-opacity:1;background-color:rgb(3 105 161/var(--tw-bg-opacity))}.dark\:hover\:bg-sky-800:hover{--tw-bg-opacity:1;background-color:rgb(7 89 133/var(--tw-bg-opacity))}.dark\:hover\:text-sky-100:hover{--tw-text-opacity:1;color:rgb(224 242 254/var(--tw-text-opacity))}} \ No newline at end of file diff --git a/static/js/json-enc.js b/static/js/json-enc.js index 0deacaa..7b5e586 100644 --- a/static/js/json-enc.js +++ b/static/js/json-enc.js @@ -4,37 +4,154 @@ htmx.defineExtension('json-enc', { evt.detail.headers['Content-Type'] = "application/json"; } }, - - encodeParameters: function(xhr, parameters, elt) { + + encodeParameters: function (xhr, parameters, elt) { xhr.overrideMimeType('application/json'); - const nestedParams = {}; + const form = (elt && elt.closest) ? (elt.closest('form') || (elt.tagName === 'FORM' ? elt : null)) : null; + if (!form || !window.AliceTraINTForm) { + return JSON.stringify({}); + } + return JSON.stringify(window.AliceTraINTForm.encodeForm(form)); + } +}); - function parseValue(value) { - const floatValue = parseFloat(value); - if (!isNaN(floatValue) && floatValue.toString() === value) { - return floatValue; +window.AliceTraINTForm = (function () { + function setNested(root, path, value) { + const parts = path.split('.'); + let current = root; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (i === parts.length - 1) { + current[part] = value; + return; } - - const intValue = parseInt(value, 10); - if (!isNaN(intValue) && intValue.toString() === value) { - return intValue; + if (!current[part] || typeof current[part] !== 'object' || Array.isArray(current[part])) { + current[part] = {}; } + current = current[part]; + } + } + + function parseList(value, jsonType) { + const parts = String(value) + .split(',') + .map(function (part) { return part.trim(); }) + .filter(function (part) { return part.length > 0; }); + if (jsonType === 'uint_list') { + return parts.map(function (part) { return parseInt(part, 10); }); + } + return parts; + } + function parseScalar(value, jsonType) { + if (jsonType === 'bool') { + return value === true || value === 'true'; + } + if (jsonType === 'select') { + if (value === '' || value === undefined || value === null) { + return null; + } return value; } + if (jsonType === 'uint' || jsonType === 'int') { + const asInt = parseInt(value, 10); + return isNaN(asInt) ? value : asInt; + } + if (jsonType === 'float64') { + const asFloat = parseFloat(value); + return isNaN(asFloat) ? value : asFloat; + } + if (jsonType === 'uint_list' || jsonType === 'string_list') { + return parseList(value, jsonType); + } + return value; + } + + function syncBoolHidden(checkbox) { + const targetName = checkbox.getAttribute('data-bool-target'); + if (!targetName) { + return; + } + const form = checkbox.closest('form'); + if (!form) { + return; + } + const hidden = form.querySelector('input[type="hidden"][name="' + targetName + '"]'); + if (hidden) { + hidden.value = checkbox.checked ? 'true' : 'false'; + } + } + + function encodeForm(form) { + form.querySelectorAll('input[type="checkbox"][data-bool-target]').forEach(syncBoolHidden); + + const nestedParams = {}; + const rangeParts = {}; + const controls = form.querySelectorAll('input[name], select[name], textarea[name]'); - for (const [name, value] of Object.entries(parameters)) { - const nameParts = name.split('.'); - let current = nestedParams; + controls.forEach(function (control) { + if (control.disabled) { + return; + } - nameParts.forEach((part, index) => { - if (!current[part]) { - current[part] = (index === nameParts.length - 1) ? parseValue(value) : {}; + if (control.type === 'checkbox') { + return; + } + + const jsonType = control.getAttribute('data-json-type') || ''; + const name = control.name; + + if (jsonType === 'float_range' || jsonType === 'int_range') { + const parent = control.getAttribute('data-range-parent'); + const index = parseInt(control.getAttribute('data-range-index'), 10); + if (!parent || isNaN(index)) { + return; } - current = current[part]; - }); - } + if (!rangeParts[parent]) { + rangeParts[parent] = { type: jsonType, values: [] }; + } + const raw = control.value; + rangeParts[parent].values[index] = jsonType === 'int_range' + ? parseInt(raw, 10) + : parseFloat(raw); + return; + } - return JSON.stringify(nestedParams); + if (control.tagName === 'SELECT') { + const selected = control.options[control.selectedIndex]; + if (jsonType === 'select') { + if (selected && selected.getAttribute('data-null') === 'true') { + setNested(nestedParams, name, null); + return; + } + setNested(nestedParams, name, parseScalar(control.value, 'select')); + return; + } + + const raw = control.value; + if (raw === '') { + return; + } + if (/^\d+$/.test(raw)) { + setNested(nestedParams, name, parseInt(raw, 10)); + return; + } + setNested(nestedParams, name, raw); + return; + } + + setNested(nestedParams, name, parseScalar(control.value, jsonType)); + }); + + Object.keys(rangeParts).forEach(function (name) { + setNested(nestedParams, name, rangeParts[name].values); + }); + + return nestedParams; } -}); + + return { + encodeForm: encodeForm, + syncBoolHidden: syncBoolHidden + }; +})(); diff --git a/static/js/nn-config-form.js b/static/js/nn-config-form.js new file mode 100644 index 0000000..2d40c4d --- /dev/null +++ b/static/js/nn-config-form.js @@ -0,0 +1,207 @@ +(function () { + function parseEquals(raw) { + try { + return JSON.parse(raw); + } catch (err) { + return raw; + } + } + + function matchesEquals(actual, expected) { + if (Array.isArray(expected)) { + return expected.map(String).includes(String(actual)); + } + if (expected === null || expected === undefined) { + return actual === '' || actual === null || actual === undefined; + } + return String(actual) === String(expected); + } + + function getConfigValue(form, fieldPath) { + const name = 'configuration.' + fieldPath; + const boolCheckbox = form.querySelector('input[type="checkbox"][data-bool-target="' + name + '"]'); + if (boolCheckbox) { + return boolCheckbox.checked; + } + const control = form.querySelector('[name="' + name + '"]'); + if (!control) { + return undefined; + } + if (control.tagName === 'SELECT') { + const selected = control.options[control.selectedIndex]; + if (selected && selected.getAttribute('data-null') === 'true') { + return null; + } + return control.value; + } + return control.value; + } + + function setGroupEnabled(group, enabled) { + group.classList.toggle('hidden', !enabled); + group.querySelectorAll('input, select, textarea').forEach(function (control) { + const nestedOwner = control.closest('.nn-field-group'); + if (nestedOwner !== group && nestedOwner && nestedOwner.hasAttribute('data-show-when-field')) { + return; + } + control.disabled = !enabled; + }); + } + + function refreshShowWhen(form) { + const groups = Array.from(form.querySelectorAll('.nn-field-group[data-show-when-field]')); + groups.forEach(function (group) { + const field = group.getAttribute('data-show-when-field'); + const expected = parseEquals(group.getAttribute('data-show-when-equals')); + const actual = getConfigValue(form, field); + + let parentVisible = true; + let parent = group.parentElement ? group.parentElement.closest('.nn-field-group') : null; + while (parent) { + if (parent.classList.contains('hidden')) { + parentVisible = false; + break; + } + parent = parent.parentElement ? parent.parentElement.closest('.nn-field-group') : null; + } + + const visible = parentVisible && matchesEquals(actual, expected); + setGroupEnabled(group, visible); + }); + } + + function setControlValue(form, path, value) { + const boolCheckbox = form.querySelector('input[type="checkbox"][data-bool-target="' + path + '"]'); + if (boolCheckbox) { + boolCheckbox.checked = Boolean(value); + if (window.AliceTraINTForm) { + window.AliceTraINTForm.syncBoolHidden(boolCheckbox); + } + return true; + } + + const rangeMin = form.querySelector('[data-range-parent="' + path + '"][data-range-index="0"]'); + const rangeMax = form.querySelector('[data-range-parent="' + path + '"][data-range-index="1"]'); + if (rangeMin && rangeMax && Array.isArray(value) && value.length >= 2) { + rangeMin.value = value[0]; + rangeMax.value = value[1]; + return true; + } + + const control = form.querySelector('[name="' + path + '"]'); + if (!control) { + return false; + } + + if (control.tagName === 'SELECT') { + if (value === null || value === undefined) { + const nullOpt = control.querySelector('option[data-null="true"]'); + if (nullOpt) { + nullOpt.selected = true; + return true; + } + control.value = ''; + return true; + } + control.value = String(value); + return true; + } + + const jsonType = control.getAttribute('data-json-type') || ''; + if ((jsonType === 'uint_list' || jsonType === 'string_list') && Array.isArray(value)) { + control.value = value.join(', '); + return true; + } + + if (value === null || value === undefined) { + control.value = ''; + return true; + } + control.value = String(value); + return true; + } + + function applyPrefillObject(form, obj, prefix) { + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) { + return; + } + Object.keys(obj).forEach(function (key) { + const path = prefix ? prefix + '.' + key : key; + const value = obj[key]; + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + applyPrefillObject(form, value, path); + return; + } + setControlValue(form, path, value); + }); + } + + function applyPrefill(form) { + const el = document.getElementById('prefill-task-config'); + if (!el) { + return; + } + const raw = el.textContent.trim(); + if (!raw || raw === 'null') { + return; + } + let config; + try { + config = JSON.parse(raw); + } catch (err) { + return; + } + if (!config || typeof config !== 'object') { + return; + } + + // Apply twice: selectors first (architecture/optimizer/...), then nested after groups shown. + applyPrefillObject(form, config, 'configuration'); + refreshShowWhen(form); + applyPrefillObject(form, config, 'configuration'); + refreshShowWhen(form); + initBools(form); + } + + function initBools(form) { + form.querySelectorAll('input[type="checkbox"][data-bool-target]').forEach(function (checkbox) { + if (window.AliceTraINTForm) { + window.AliceTraINTForm.syncBoolHidden(checkbox); + } + checkbox.addEventListener('change', function () { + if (window.AliceTraINTForm) { + window.AliceTraINTForm.syncBoolHidden(checkbox); + } + }); + }); + } + + function initForm(form) { + initBools(form); + refreshShowWhen(form); + applyPrefill(form); + form.addEventListener('change', function (event) { + if (!event.target.matches('select, input')) { + return; + } + if (event.target.matches('input[type="checkbox"][data-bool-target]') && window.AliceTraINTForm) { + window.AliceTraINTForm.syncBoolHidden(event.target); + } + refreshShowWhen(form); + }); + form.addEventListener('submit', function () { + form.querySelectorAll('input[type="checkbox"][data-bool-target]').forEach(function (checkbox) { + if (window.AliceTraINTForm) { + window.AliceTraINTForm.syncBoolHidden(checkbox); + } + }); + }); + } + + document.addEventListener('DOMContentLoaded', function () { + const form = document.getElementById('create-training-task-form'); + if (form) { + initForm(form); + } + }); +})(); diff --git a/static/js/task-config-actions.js b/static/js/task-config-actions.js new file mode 100644 index 0000000..ac6a53f --- /dev/null +++ b/static/js/task-config-actions.js @@ -0,0 +1,47 @@ +(function () { + function getRawConfig() { + const el = document.getElementById('raw-task-config'); + if (!el) { + return null; + } + const text = el.textContent.trim(); + if (!text || text === 'null') { + return null; + } + return text; + } + + document.addEventListener('DOMContentLoaded', function () { + const code = document.getElementById('task-config-code'); + if (code && window.hljs) { + window.hljs.highlightElement(code); + } + + const copyBtn = document.getElementById('copy-task-config'); + if (!copyBtn) { + return; + } + copyBtn.addEventListener('click', async function () { + const raw = getRawConfig(); + if (!raw) { + return; + } + try { + let pretty = raw; + try { + pretty = JSON.stringify(JSON.parse(raw), null, 2); + } catch (err) { + // keep raw + } + await navigator.clipboard.writeText(pretty); + const prev = copyBtn.textContent; + copyBtn.textContent = 'Copied'; + setTimeout(function () { + copyBtn.textContent = prev; + }, 1500); + } catch (err) { + copyBtn.textContent = 'Copy failed'; + } + }); + }); +})(); diff --git a/test/integration/common.go b/test/integration/common.go index 838d909..de499c3 100644 --- a/test/integration/common.go +++ b/test/integration/common.go @@ -29,6 +29,7 @@ func init() { type MockedServices struct { CCDB *service.MockCCDBService JAliEn *service.MockJAliEnService + Monalisa *service.MockMonalisaService FileService *service.MockFileService Auth *auth.AuthServiceMock NNArch *service.NNArchServiceInMemory @@ -54,15 +55,24 @@ func mockRouter(db *gorm.DB, cfg *config.Config) *IntegrationTestUtils { hasher := service.NewMockHasher() ccdbService := service.NewMockCCDBService() jalienService := service.NewMockJAliEnService() - nnArch := service.NewNNArchServiceInMemory(&service.NNFieldConfigs{ - "fieldName": service.NNConfigField{ - FullName: "Full field name", - Type: "uint", - DefaultValue: uint(512), - Min: uint(128), - Max: uint(1024), - Step: uint(1), - Description: "Field description", + monalisaService := service.NewMockMonalisaService() + nnArch := service.NewNNArchServiceInMemory([]service.NNFieldGroup{ + { + Name: "training", + Path: "training", + FullName: "Training", + Fields: []service.NNConfigField{ + { + Name: "batch_size", + FullName: "Full field name", + Type: "uint", + DefaultValue: uint(512), + Min: uint(128), + Max: uint(1024), + Step: uint(1), + Description: "Field description", + }, + }, }, }, &service.NNExpectedResults{ Onnx: map[string]string{ @@ -73,8 +83,8 @@ func mockRouter(db *gorm.DB, cfg *config.Config) *IntegrationTestUtils { // handlers' routes handler.InitLandingRoutes(mux, env) - handler.InitTrainingDatasetRoutes(mux, env, jalienService, nil) - handler.InitTrainingTaskRoutes(mux, env, ccdbService, jalienService, fileService, nnArch) + handler.InitTrainingDatasetRoutes(mux, env, jalienService, ccdbService, monalisaService, nil) + handler.InitTrainingTaskRoutes(mux, env, ccdbService, monalisaService, fileService, nnArch) handler.InitTrainingMachineRoutes(mux, env, hasher) handler.InitQueueRoutes(mux, env, fileService, hasher) @@ -84,6 +94,7 @@ func mockRouter(db *gorm.DB, cfg *config.Config) *IntegrationTestUtils { MockedServices: &MockedServices{ CCDB: ccdbService, JAliEn: jalienService, + Monalisa: monalisaService, FileService: fileService, Auth: auth, NNArch: nnArch, diff --git a/test/integration/training_dataset_handler_test.go b/test/integration/training_dataset_handler_test.go index 46bca4b..d3e48c3 100644 --- a/test/integration/training_dataset_handler_test.go +++ b/test/integration/training_dataset_handler_test.go @@ -7,9 +7,12 @@ import ( "fmt" "net/http" "testing" + "time" + "github.com/mytkom/AliceTraINT/internal/ccdb" "github.com/mytkom/AliceTraINT/internal/db/models" "github.com/mytkom/AliceTraINT/internal/jalien" + "github.com/mytkom/AliceTraINT/internal/monalisa" "github.com/stretchr/testify/assert" ) @@ -165,6 +168,24 @@ type createTrainingDatasetPayload struct { UserId uint } +func mockDatasetTimestampRange(ut *IntegrationTestUtils, period string, minRun, maxRun uint64) { + ut.Monalisa.On("GetRunList").Return(&monalisa.HyperloopRunList{ + TagToRuns: map[string][]uint64{ + period: {minRun, maxRun}, + }, + }, nil).Once() + ut.CCDB.On("GetRunInformation", minRun).Return(&ccdb.RunInformation{ + RunNumber: minRun, + SOR: uint64(time.Now().Add(-time.Hour).UnixMilli()), + EOR: uint64(time.Now().Add(-30 * time.Minute).UnixMilli()), + }, nil).Once() + ut.CCDB.On("GetRunInformation", maxRun).Return(&ccdb.RunInformation{ + RunNumber: maxRun, + SOR: uint64(time.Now().Add(-20 * time.Minute).UnixMilli()), + EOR: uint64(time.Now().UnixMilli()), + }, nil).Once() +} + func TestTrainingDatasetHandler_Create_Failure(t *testing.T) { ut, cleanup := setupIntegrationTest(t) defer cleanup() @@ -201,6 +222,8 @@ func TestTrainingDatasetHandler_Create_Failure(t *testing.T) { body, err := json.Marshal(tdSameName) assert.NoError(t, err) + mockDatasetTimestampRange(ut, "LHC24b1b", 567454, 567456) + req, err := http.NewRequest("POST", "/training-datasets", bytes.NewReader(body)) assert.NoError(t, err) HTMXReq(req) @@ -236,6 +259,8 @@ func TestTrainingDatasetHandler_Create(t *testing.T) { body, err := json.Marshal(trainingDataset) assert.NoError(t, err) + mockDatasetTimestampRange(ut, "LHC24b1b", 567454, 567454) + req, err := http.NewRequest("POST", "/training-datasets", bytes.NewReader(body)) assert.NoError(t, err) HTMXReq(req) diff --git a/test/integration/training_task_handler_test.go b/test/integration/training_task_handler_test.go index 4662f0d..f015386 100644 --- a/test/integration/training_task_handler_test.go +++ b/test/integration/training_task_handler_test.go @@ -187,7 +187,7 @@ func TestTrainingTaskHandler_New(t *testing.T) { assert.Equal(t, http.StatusOK, rr.Code) responseBody := rr.Body.String() assert.Contains(t, responseBody, "Unique Dataset Name") - assert.Contains(t, responseBody, "fieldName") + assert.Contains(t, responseBody, "configuration.training.batch_size") assert.Contains(t, responseBody, "512") } @@ -293,21 +293,14 @@ func prepareUploadToCCDB(t *testing.T, ut *IntegrationTestUtils, user *models.Us assert.NoError(t, ut.TrainingTask.Create(trainingTask)) now := uint64(time.Now().UTC().UnixMilli()) - ut.MockedServices.JAliEn.On("ListAndParseDirectory", "/alice/sim/2024/LHC24b1b/0").Return(&jalien.DirectoryContents{ - Subdirs: []jalien.Dir{ - {Name: "560000", Path: "/alice/sim/2024/LHC24b1b/0/560000"}, - {Name: "567454", Path: "/alice/sim/2024/LHC24b1b/0/567454"}, - {Name: "567458", Path: "/alice/sim/2024/LHC24b1b/0/567458"}, - {Name: "570000", Path: "/alice/sim/2024/LHC24b1b/0/570000"}, - }, - }, nil) - ut.MockedServices.CCDB.On("GetRunInformation", uint64(560000)).Return(&ccdb.RunInformation{ - RunNumber: 560000, + ut.MockedServices.Monalisa.On("GetRunList").Return(nil, errors.New("monalisa down")) + ut.MockedServices.CCDB.On("GetRunInformation", uint64(567454)).Return(&ccdb.RunInformation{ + RunNumber: 567454, SOR: now - 10000, EOR: now, }, nil) - ut.MockedServices.CCDB.On("GetRunInformation", uint64(570000)).Return(&ccdb.RunInformation{ - RunNumber: 570000, + ut.MockedServices.CCDB.On("GetRunInformation", uint64(567458)).Return(&ccdb.RunInformation{ + RunNumber: 567458, SOR: now, EOR: now + 10000, }, nil) diff --git a/test/repository/training_dataset_repository_test.go b/test/repository/training_dataset_repository_test.go index c75778b..a8edb13 100644 --- a/test/repository/training_dataset_repository_test.go +++ b/test/repository/training_dataset_repository_test.go @@ -40,7 +40,7 @@ func TestTrainingDatasetRepository_Create(t *testing.T) { mock.ExpectBegin() mock.ExpectQuery(`INSERT INTO "training_datasets" (.+) RETURNING "id"`). - WithArgs(AnyTime(), AnyTime(), AnyTime(), trainingDataset.Name, marshalAODFiles(t, trainingDataset), 1). + WithArgs(AnyTime(), AnyTime(), AnyTime(), trainingDataset.Name, marshalAODFiles(t, trainingDataset), AnyTime(), AnyTime(), 1). WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) mock.ExpectCommit() diff --git a/test/repository/training_task_repository_test.go b/test/repository/training_task_repository_test.go index ae4a8f6..6776241 100644 --- a/test/repository/training_task_repository_test.go +++ b/test/repository/training_task_repository_test.go @@ -48,7 +48,7 @@ func TestTrainingTaskRepository_Create(t *testing.T) { mock.ExpectBegin() mock.ExpectQuery(`INSERT INTO "training_datasets" (.+) RETURNING "id"`). - WithArgs(AnyTime(), AnyTime(), AnyTime(), trainingDataset.Name, marshalAODFiles(t, trainingDataset), 1). + WithArgs(AnyTime(), AnyTime(), AnyTime(), trainingDataset.Name, marshalAODFiles(t, trainingDataset), AnyTime(), AnyTime(), 1). WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) mock.ExpectQuery(`INSERT INTO "training_tasks" (.+) RETURNING "id"`). WithArgs(AnyTime(), AnyTime(), AnyTime(), trainingTask.Name, trainingTask.Status, 1, 1, nil, marshalTrainingTaskConfig(t, trainingTask)). diff --git a/test/service/training_dataset_service_test.go b/test/service/training_dataset_service_test.go index e768be8..e3d9705 100644 --- a/test/service/training_dataset_service_test.go +++ b/test/service/training_dataset_service_test.go @@ -11,18 +11,24 @@ import ( ) type trainingDatasetServiceTestUtils struct { - TDRepo *repository.MockTrainingDatasetRepository - JAliEnService *service.MockJAliEnService + TDRepo *repository.MockTrainingDatasetRepository + JAliEnService *service.MockJAliEnService + CCDBService *service.MockCCDBService + MonalisaService *service.MockMonalisaService } func newTrainingDatasetService() (*service.TrainingDatasetService, *trainingDatasetServiceTestUtils) { tdRepo := repository.NewMockTrainingDatasetRepository() jalienService := service.NewMockJAliEnService() + ccdbService := service.NewMockCCDBService() + monalisaService := service.NewMockMonalisaService() return service.NewTrainingDatasetService(&repository.RepositoryContext{ TrainingDataset: tdRepo, - }, jalienService), &trainingDatasetServiceTestUtils{ - TDRepo: tdRepo, - JAliEnService: jalienService, + }, jalienService, ccdbService, monalisaService), &trainingDatasetServiceTestUtils{ + TDRepo: tdRepo, + JAliEnService: jalienService, + CCDBService: ccdbService, + MonalisaService: monalisaService, } } diff --git a/test/service/training_task_service_test.go b/test/service/training_task_service_test.go index 43bf4da..bc60778 100644 --- a/test/service/training_task_service_test.go +++ b/test/service/training_task_service_test.go @@ -11,6 +11,7 @@ import ( "github.com/mytkom/AliceTraINT/internal/db/models" "github.com/mytkom/AliceTraINT/internal/db/repository" "github.com/mytkom/AliceTraINT/internal/jalien" + "github.com/mytkom/AliceTraINT/internal/monalisa" "github.com/mytkom/AliceTraINT/internal/service" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -18,31 +19,39 @@ import ( ) type trainingTaskServiceTestUtils struct { - TTRepo *repository.MockTrainingTaskRepository - TDRepo *repository.MockTrainingDatasetRepository - TTRRepo *repository.MockTrainingTaskResultRepository - CCDBService *service.MockCCDBService - JAliEnService *service.MockJAliEnService - FileService *service.MockFileService - NNArch *service.NNArchServiceInMemory + TTRepo *repository.MockTrainingTaskRepository + TDRepo *repository.MockTrainingDatasetRepository + TTRRepo *repository.MockTrainingTaskResultRepository + CCDBService *service.MockCCDBService + MonalisaService *service.MockMonalisaService + FileService *service.MockFileService + NNArch *service.NNArchServiceInMemory } func newTrainingTaskService() (*service.TrainingTaskService, *trainingTaskServiceTestUtils) { ttRepo := repository.NewMockTrainingTaskRepository() tdRepo := repository.NewMockTrainingDatasetRepository() ttrRepo := repository.NewMockTrainingTaskResultRepository() - jalienService := service.NewMockJAliEnService() ccdbService := service.NewMockCCDBService() + monalisaService := service.NewMockMonalisaService() fileService := service.NewMockFileService() - nnArch := service.NewNNArchServiceInMemory(&service.NNFieldConfigs{ - "fieldName": service.NNConfigField{ - FullName: "Full field name", - Type: "uint", - DefaultValue: uint(512), - Min: uint(128), - Max: uint(1024), - Step: uint(1), - Description: "Field description", + nnArch := service.NewNNArchServiceInMemory([]service.NNFieldGroup{ + { + Name: "training", + Path: "training", + FullName: "Training", + Fields: []service.NNConfigField{ + { + Name: "batch_size", + FullName: "Batch size", + Type: "uint", + DefaultValue: uint(512), + Min: uint(128), + Max: uint(1024), + Step: uint(1), + Description: "Field description", + }, + }, }, }, &service.NNExpectedResults{ Onnx: map[string]string{ @@ -54,14 +63,14 @@ func newTrainingTaskService() (*service.TrainingTaskService, *trainingTaskServic TrainingTask: ttRepo, TrainingDataset: tdRepo, TrainingTaskResult: ttrRepo, - }, ccdbService, jalienService, fileService, nnArch), &trainingTaskServiceTestUtils{ - TTRepo: ttRepo, - TDRepo: tdRepo, - TTRRepo: ttrRepo, - CCDBService: ccdbService, - JAliEnService: jalienService, - FileService: fileService, - NNArch: nnArch, + }, ccdbService, monalisaService, fileService, nnArch), &trainingTaskServiceTestUtils{ + TTRepo: ttRepo, + TDRepo: tdRepo, + TTRRepo: ttrRepo, + CCDBService: ccdbService, + MonalisaService: monalisaService, + FileService: fileService, + NNArch: nnArch, } } @@ -155,7 +164,7 @@ func TestTrainingTaskService_GetHelpers(t *testing.T) { ut.TDRepo.AssertCalled(t, "GetAllUser", userId) assert.Equal(t, tds[0].Name, helpers.TrainingDatasets[0].Name) assert.Equal(t, tds[1].Name, helpers.TrainingDatasets[1].Name) - assert.True(t, reflect.DeepEqual(helpers.FieldConfigs, ut.NNArch.FieldConfigs)) + assert.True(t, reflect.DeepEqual(helpers.FieldGroups, ut.NNArch.FieldGroups)) } func TestTrainingTaskService_GetByID_Queued(t *testing.T) { @@ -396,27 +405,17 @@ func TestTrainingTaskService_UploadToCCDB_OnePeriod(t *testing.T) { ut.TTRepo.On("GetByID", ttId).Return(&tt, nil) ut.TTRepo.On("Update", &tt).Return(nil) ut.TTRRepo.On("GetByType", ttId, models.Onnx).Return(onnxFiles, nil) + ut.MonalisaService.On("GetRunList").Return(nil, errors.New("monalisa down")).Once() file := &mockReadCloser{} ut.FileService.On("OpenFile", "./local_file_temp.onnx").Return(file, func(r io.ReadCloser) { r.Close() }, nil) - ut.JAliEnService.On("ListAndParseDirectory", "/alice/sim/2024/LHC24f3/0").Return(&jalien.DirectoryContents{ - Subdirs: []jalien.Dir{ - {Name: "321000", Path: "/alice/sim/2024/LHC24f3/0/321000"}, - {Name: "321100", Path: "/alice/sim/2024/LHC24f3/0/321100"}, - {Name: "321321", Path: "/alice/sim/2024/LHC24f3/0/321321"}, - {Name: "321326", Path: "/alice/sim/2024/LHC24f3/0/321326"}, - {Name: "321338", Path: "/alice/sim/2024/LHC24f3/0/321338"}, - {Name: "321400", Path: "/alice/sim/2024/LHC24f3/0/321400"}, - {Name: "321500", Path: "/alice/sim/2024/LHC24f3/0/321500"}, - }, - }, nil) now := time.Now().UnixMilli() - ut.CCDBService.On("GetRunInformation", uint64(321000)).Return(&ccdb.RunInformation{ - RunNumber: 321000, + ut.CCDBService.On("GetRunInformation", uint64(321321)).Return(&ccdb.RunInformation{ + RunNumber: 321321, SOR: uint64(now - 10000), EOR: uint64(now - 9000), }, nil) - ut.CCDBService.On("GetRunInformation", uint64(321500)).Return(&ccdb.RunInformation{ - RunNumber: 321500, + ut.CCDBService.On("GetRunInformation", uint64(321338)).Return(&ccdb.RunInformation{ + RunNumber: 321338, SOR: uint64(now + 7000), EOR: uint64(now + 10000), }, nil) @@ -432,9 +431,8 @@ func TestTrainingTaskService_UploadToCCDB_OnePeriod(t *testing.T) { assert.Equal(t, models.Uploaded, tt.Status) ut.TTRRepo.AssertCalled(t, "GetByType", ttId, models.Onnx) ut.FileService.AssertCalled(t, "OpenFile", "./local_file_temp.onnx") - ut.JAliEnService.AssertCalled(t, "ListAndParseDirectory", "/alice/sim/2024/LHC24f3/0") - ut.CCDBService.AssertCalled(t, "GetRunInformation", uint64(321000)) - ut.CCDBService.AssertCalled(t, "GetRunInformation", uint64(321500)) + ut.CCDBService.AssertCalled(t, "GetRunInformation", uint64(321321)) + ut.CCDBService.AssertCalled(t, "GetRunInformation", uint64(321338)) ut.CCDBService.AssertCalled(t, "UploadFile", uint64(now-10000), uint64(now+10000), "uploaded_file.onnx", file) } @@ -478,75 +476,23 @@ func TestTrainingTaskService_UploadToCCDB_ManyPeriods(t *testing.T) { ut.TTRepo.On("GetByID", ttId).Return(&tt, nil) ut.TTRepo.On("Update", &tt).Return(nil) ut.TTRRepo.On("GetByType", ttId, models.Onnx).Return(onnxFiles, nil) + ut.MonalisaService.On("GetRunList").Return(nil, errors.New("monalisa down")).Once() file := &mockReadCloser{} ut.FileService.On("OpenFile", "./local_file_temp.onnx").Return(file, func(r io.ReadCloser) { r.Close() }, nil) - // Mock JAliEn and CCDB with timestamp information now := time.Now().UnixMilli() - // LHC24b1b - ut.JAliEnService.On("ListAndParseDirectory", "/alice/sim/2024/LHC24b1b/0").Return(&jalien.DirectoryContents{ - Subdirs: []jalien.Dir{ - {Name: "319900", Path: "/alice/sim/2024/LHC24b1b/0/319900"}, // min run number LHC24b1b - {Name: "320000", Path: "/alice/sim/2024/LHC24b1b/0/320000"}, - {Name: "320100", Path: "/alice/sim/2024/LHC24b1b/0/320100"}, - {Name: "320200", Path: "/alice/sim/2024/LHC24b1b/0/320200"}, - {Name: "320300", Path: "/alice/sim/2024/LHC24b1b/0/320300"}, // max run number LHC24b1b - }, - }, nil) - ut.CCDBService.On("GetRunInformation", uint64(319900)).Return(&ccdb.RunInformation{ - RunNumber: 319900, + ut.CCDBService.On("GetRunInformation", uint64(320000)).Return(&ccdb.RunInformation{ + RunNumber: 320000, SOR: uint64(now - 20000), EOR: uint64(now - 19000), }, nil) - ut.CCDBService.On("GetRunInformation", uint64(320300)).Return(&ccdb.RunInformation{ - RunNumber: 320300, - SOR: uint64(now - 12000), - EOR: uint64(now - 11000), - }, nil) - // LHC24f3 - ut.JAliEnService.On("ListAndParseDirectory", "/alice/sim/2024/LHC24f3/0").Return(&jalien.DirectoryContents{ - Subdirs: []jalien.Dir{ - {Name: "321000", Path: "/alice/sim/2024/LHC24f3/0/321000"}, // min run number LHC24f3 - {Name: "321321", Path: "/alice/sim/2024/LHC24f3/0/321321"}, - {Name: "321326", Path: "/alice/sim/2024/LHC24f3/0/321326"}, - {Name: "321338", Path: "/alice/sim/2024/LHC24f3/0/321338"}, - {Name: "321500", Path: "/alice/sim/2024/LHC24f3/0/321500"}, // max run number LHC24f3 - }, - }, nil) - ut.CCDBService.On("GetRunInformation", uint64(321000)).Return(&ccdb.RunInformation{ - RunNumber: 321000, - SOR: uint64(now - 10000), - EOR: uint64(now - 9000), - }, nil) - ut.CCDBService.On("GetRunInformation", uint64(321500)).Return(&ccdb.RunInformation{ - RunNumber: 321500, - SOR: uint64(now - 2000), - EOR: uint64(now - 1000), - }, nil) - // LHC24c1 - // simulate 2023 datasets (without numbered subdir in period dir) - ut.JAliEnService.On("ListAndParseDirectory", "/alice/sim/2024/LHC24c1").Return(&jalien.DirectoryContents{ - Subdirs: []jalien.Dir{ - {Name: "321900", Path: "/alice/sim/2024/LHC24c1/321900"}, // min run number LHC24c1 - {Name: "322000", Path: "/alice/sim/2024/LHC24c1/322000"}, - {Name: "322100", Path: "/alice/sim/2024/LHC24c1/322100"}, - {Name: "322200", Path: "/alice/sim/2024/LHC24c1/322200"}, - {Name: "322300", Path: "/alice/sim/2024/LHC24c1/322300"}, // max run number LHC24c1 - }, - }, nil) - ut.CCDBService.On("GetRunInformation", uint64(321900)).Return(&ccdb.RunInformation{ - RunNumber: 321900, - SOR: uint64(now), - EOR: uint64(now + 1000), - }, nil) - ut.CCDBService.On("GetRunInformation", uint64(322300)).Return(&ccdb.RunInformation{ - RunNumber: 322300, + ut.CCDBService.On("GetRunInformation", uint64(322200)).Return(&ccdb.RunInformation{ + RunNumber: 322200, SOR: uint64(now + 9000), EOR: uint64(now + 10000), }, nil) - // TODO b1b and c1 ut.CCDBService.On("UploadFile", uint64(now-20000), uint64(now+10000), "uploaded_file.onnx", file).Return(nil) // Act @@ -559,19 +505,68 @@ func TestTrainingTaskService_UploadToCCDB_ManyPeriods(t *testing.T) { assert.Equal(t, models.Uploaded, tt.Status) ut.TTRRepo.AssertCalled(t, "GetByType", ttId, models.Onnx) ut.FileService.AssertCalled(t, "OpenFile", "./local_file_temp.onnx") - // LHC24b1b info - ut.JAliEnService.AssertCalled(t, "ListAndParseDirectory", "/alice/sim/2024/LHC24b1b/0") - ut.CCDBService.AssertCalled(t, "GetRunInformation", uint64(319900)) - ut.CCDBService.AssertCalled(t, "GetRunInformation", uint64(320300)) - // LHC24f3 info - ut.JAliEnService.AssertCalled(t, "ListAndParseDirectory", "/alice/sim/2024/LHC24f3/0") - ut.CCDBService.AssertCalled(t, "GetRunInformation", uint64(321000)) - ut.CCDBService.AssertCalled(t, "GetRunInformation", uint64(321500)) - // LHC24c1 info - ut.JAliEnService.AssertCalled(t, "ListAndParseDirectory", "/alice/sim/2024/LHC24c1") - ut.CCDBService.AssertCalled(t, "GetRunInformation", uint64(321900)) - ut.CCDBService.AssertCalled(t, "GetRunInformation", uint64(322300)) - // Upload + ut.CCDBService.AssertCalled(t, "GetRunInformation", uint64(320000)) + ut.CCDBService.AssertCalled(t, "GetRunInformation", uint64(322200)) + ut.CCDBService.AssertCalled(t, "UploadFile", uint64(now-20000), uint64(now+10000), "uploaded_file.onnx", file) +} + +func TestTrainingTaskService_UploadToCCDB_UsesMonalisaFirst(t *testing.T) { + ttService, ut := newTrainingTaskService() + userId := uint(1) + tdId := uint(1) + ttId := uint(1) + tt := models.TrainingTask{ + Model: gorm.Model{ID: ttId}, + Name: "task2", + UserId: userId, + Status: models.Completed, + TrainingDatasetId: tdId, + TrainingDataset: models.TrainingDataset{ + Model: gorm.Model{ID: tdId}, + UserId: userId, + AODFiles: []jalien.AODFile{ + {Name: "AO2D.root", Path: "/alice/sim/2024/LHC24b1b/0/320000/AOD/002", RunNumber: 320000, LHCPeriod: "LHC24b1b", AODNumber: 2}, + {Name: "AO2D.root", Path: "/alice/sim/2024/LHC24f3/0/321338/AOD/002", RunNumber: 321338, LHCPeriod: "LHC24f3", AODNumber: 2}, + }, + }, + Configuration: "", + } + onnxFiles := []models.TrainingTaskResult{ + {Name: "local_file.onnx", Type: models.Onnx, Description: "example", FileId: 1, File: models.File{ + Name: "local_file_temp.onnx", Path: "./local_file_temp.onnx", Size: 12312, + }, TrainingTaskId: ttId}, + } + + ut.TTRepo.On("GetByID", ttId).Return(&tt, nil) + ut.TTRepo.On("Update", &tt).Return(nil) + ut.TTRRepo.On("GetByType", ttId, models.Onnx).Return(onnxFiles, nil) + ut.MonalisaService.On("GetRunList").Return(&monalisa.HyperloopRunList{ + TagToRuns: map[string][]uint64{ + "LHC24b1b": {319900, 320300}, + "LHC24f3": {321000, 321500}, + }, + }, nil).Once() + + file := &mockReadCloser{} + ut.FileService.On("OpenFile", "./local_file_temp.onnx").Return(file, func(r io.ReadCloser) { r.Close() }, nil) + + now := time.Now().UnixMilli() + ut.CCDBService.On("GetRunInformation", uint64(319900)).Return(&ccdb.RunInformation{ + RunNumber: 319900, + SOR: uint64(now - 20000), + EOR: uint64(now - 19000), + }, nil) + ut.CCDBService.On("GetRunInformation", uint64(321500)).Return(&ccdb.RunInformation{ + RunNumber: 321500, + SOR: uint64(now + 9000), + EOR: uint64(now + 10000), + }, nil) + ut.CCDBService.On("UploadFile", uint64(now-20000), uint64(now+10000), "uploaded_file.onnx", file).Return(nil) + + err := ttService.UploadOnnxResults(ttId) + + assert.NoError(t, err) + ut.MonalisaService.AssertCalled(t, "GetRunList") ut.CCDBService.AssertCalled(t, "UploadFile", uint64(now-20000), uint64(now+10000), "uploaded_file.onnx", file) } @@ -606,27 +601,17 @@ func TestTrainingTaskService_UploadToCCDB_MissingExpectedFile(t *testing.T) { ut.TTRepo.On("GetByID", ttId).Return(&tt, nil) ut.TTRepo.On("Update", &tt).Return(nil) ut.TTRRepo.On("GetByType", ttId, models.Onnx).Return(onnxFiles, nil) + ut.MonalisaService.On("GetRunList").Return(nil, errors.New("monalisa down")).Once() file := &mockReadCloser{} ut.FileService.On("OpenFile", "./local_file_temp.onnx").Return(file, func(r io.ReadCloser) { r.Close() }, nil) - ut.JAliEnService.On("ListAndParseDirectory", "/alice/sim/2024/LHC24f3/0").Return(&jalien.DirectoryContents{ - Subdirs: []jalien.Dir{ - {Name: "321000", Path: "/alice/sim/2024/LHC24f3/0/321000"}, - {Name: "321100", Path: "/alice/sim/2024/LHC24f3/0/321100"}, - {Name: "321321", Path: "/alice/sim/2024/LHC24f3/0/321321"}, - {Name: "321326", Path: "/alice/sim/2024/LHC24f3/0/321326"}, - {Name: "321338", Path: "/alice/sim/2024/LHC24f3/0/321338"}, - {Name: "321400", Path: "/alice/sim/2024/LHC24f3/0/321400"}, - {Name: "321500", Path: "/alice/sim/2024/LHC24f3/0/321500"}, - }, - }, nil) now := time.Now().UnixMilli() - ut.CCDBService.On("GetRunInformation", uint64(321000)).Return(&ccdb.RunInformation{ - RunNumber: 321000, + ut.CCDBService.On("GetRunInformation", uint64(321321)).Return(&ccdb.RunInformation{ + RunNumber: 321321, SOR: uint64(now - 10000), EOR: uint64(now - 9000), }, nil) - ut.CCDBService.On("GetRunInformation", uint64(321500)).Return(&ccdb.RunInformation{ - RunNumber: 321500, + ut.CCDBService.On("GetRunInformation", uint64(321338)).Return(&ccdb.RunInformation{ + RunNumber: 321338, SOR: uint64(now + 7000), EOR: uint64(now + 10000), }, nil) @@ -642,9 +627,8 @@ func TestTrainingTaskService_UploadToCCDB_MissingExpectedFile(t *testing.T) { ut.TTRepo.AssertNotCalled(t, "Update", &tt) ut.TTRRepo.AssertCalled(t, "GetByType", ttId, models.Onnx) ut.FileService.AssertNotCalled(t, "OpenFile", "./local_file_temp.onnx") - ut.JAliEnService.AssertCalled(t, "ListAndParseDirectory", "/alice/sim/2024/LHC24f3/0") - ut.CCDBService.AssertCalled(t, "GetRunInformation", uint64(321000)) - ut.CCDBService.AssertCalled(t, "GetRunInformation", uint64(321500)) + ut.CCDBService.AssertCalled(t, "GetRunInformation", uint64(321321)) + ut.CCDBService.AssertCalled(t, "GetRunInformation", uint64(321338)) ut.CCDBService.AssertNotCalled(t, "UploadFile", uint64(now-10000), uint64(now+10000), "uploaded_file.onnx", file) } @@ -679,27 +663,17 @@ func TestTrainingTaskService_UploadToCCDB_ErrorReadingFile(t *testing.T) { ut.TTRepo.On("GetByID", ttId).Return(&tt, nil) ut.TTRepo.On("Update", &tt).Return(nil) ut.TTRRepo.On("GetByType", ttId, models.Onnx).Return(onnxFiles, nil) + ut.MonalisaService.On("GetRunList").Return(nil, errors.New("monalisa down")).Once() file := &mockReadCloser{} ut.FileService.On("OpenFile", "./local_file_temp.onnx").Return(nil, nil, errors.New("error reading file")) - ut.JAliEnService.On("ListAndParseDirectory", "/alice/sim/2024/LHC24f3/0").Return(&jalien.DirectoryContents{ - Subdirs: []jalien.Dir{ - {Name: "321000", Path: "/alice/sim/2024/LHC24f3/0/321000"}, - {Name: "321100", Path: "/alice/sim/2024/LHC24f3/0/321100"}, - {Name: "321321", Path: "/alice/sim/2024/LHC24f3/0/321321"}, - {Name: "321326", Path: "/alice/sim/2024/LHC24f3/0/321326"}, - {Name: "321338", Path: "/alice/sim/2024/LHC24f3/0/321338"}, - {Name: "321400", Path: "/alice/sim/2024/LHC24f3/0/321400"}, - {Name: "321500", Path: "/alice/sim/2024/LHC24f3/0/321500"}, - }, - }, nil) now := time.Now().UnixMilli() - ut.CCDBService.On("GetRunInformation", uint64(321000)).Return(&ccdb.RunInformation{ - RunNumber: 321000, + ut.CCDBService.On("GetRunInformation", uint64(321321)).Return(&ccdb.RunInformation{ + RunNumber: 321321, SOR: uint64(now - 10000), EOR: uint64(now - 9000), }, nil) - ut.CCDBService.On("GetRunInformation", uint64(321500)).Return(&ccdb.RunInformation{ - RunNumber: 321500, + ut.CCDBService.On("GetRunInformation", uint64(321338)).Return(&ccdb.RunInformation{ + RunNumber: 321338, SOR: uint64(now + 7000), EOR: uint64(now + 10000), }, nil) @@ -715,8 +689,7 @@ func TestTrainingTaskService_UploadToCCDB_ErrorReadingFile(t *testing.T) { ut.TTRepo.AssertNotCalled(t, "Update", &tt) ut.TTRRepo.AssertCalled(t, "GetByType", ttId, models.Onnx) ut.FileService.AssertCalled(t, "OpenFile", "./local_file_temp.onnx") - ut.JAliEnService.AssertCalled(t, "ListAndParseDirectory", "/alice/sim/2024/LHC24f3/0") - ut.CCDBService.AssertCalled(t, "GetRunInformation", uint64(321000)) - ut.CCDBService.AssertCalled(t, "GetRunInformation", uint64(321500)) + ut.CCDBService.AssertCalled(t, "GetRunInformation", uint64(321321)) + ut.CCDBService.AssertCalled(t, "GetRunInformation", uint64(321338)) ut.CCDBService.AssertNotCalled(t, "UploadFile", uint64(now-10000), uint64(now+10000), "uploaded_file.onnx", file) } diff --git a/web/nn_architectures/proposed.json b/web/nn_architectures/proposed.json index 265e8e2..a8945ad 100644 --- a/web/nn_architectures/proposed.json +++ b/web/nn_architectures/proposed.json @@ -9,129 +9,1187 @@ "antipion.onnx": "simple_model_0211.onnx" } }, - "field_configs": { - "undersample": { - "full_name": "Undersample Training Dataset", - "type": "bool", - "default_value": false, - "description": "Undersample training dataset, in result count of tracks of every missing detectors combination would be equal." + "field_groups": [ + { + "name": "data", + "path": "data", + "full_name": "Data", + "fields": [ + { + "name": "train_size", + "full_name": "Train size", + "type": "float64", + "default_value": 0.64, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "description": "Fraction of data used for training. Validation size is 1 - train_size - test_size." + }, + { + "name": "test_size", + "full_name": "Test size", + "type": "float64", + "default_value": 0.2, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "description": "Fraction of data used for testing." + }, + { + "name": "run_event_selection", + "full_name": "Run event selection", + "type": "bool", + "default_value": true, + "description": "Drop events that do not meet PIDMLProducer event selection criteria." + }, + { + "name": "use_event_data", + "full_name": "Use event data", + "type": "bool", + "default_value": true, + "description": "Include event-level columns in training features." + }, + { + "name": "use_n_sigma", + "full_name": "Use n-sigma", + "type": "bool", + "default_value": false, + "description": "Include n-sigma detector columns in training features." + }, + { + "name": "use_exp_sigma", + "full_name": "Use expected sigma", + "type": "bool", + "default_value": true, + "description": "Include expected TPC/TOF resolution columns in training features." + }, + { + "name": "outlier_filtering_method", + "full_name": "Outlier filtering method", + "type": "select", + "default_value": null, + "allow_null": true, + "options": [ + {"value": null, "label": "None"}, + {"value": "iqr", "label": "IQR"}, + {"value": "ocsvm", "label": "One-Class SVM"}, + {"value": "isolation_forest", "label": "Isolation Forest"} + ], + "description": "Optional outlier filtering applied before training." + } + ], + "groups": [ + { + "name": "outlier_filtering_methods", + "path": "data.outlier_filtering_methods", + "full_name": "Outlier filtering parameters", + "fields": [ + { + "name": "columns", + "full_name": "Filter columns", + "type": "string_list", + "default_value": ["fBeta", "fTOFSignal", "fTPCSignal", "fP"], + "description": "Columns used by the selected outlier filter (comma-separated)." + } + ], + "groups": [ + { + "name": "iqr", + "path": "data.outlier_filtering_methods.iqr", + "full_name": "IQR", + "show_when": {"field": "data.outlier_filtering_method", "equals": "iqr"}, + "fields": [ + { + "name": "multiplier", + "full_name": "IQR multiplier", + "type": "float64", + "default_value": 8.0, + "min": 0.0, + "max": 100.0, + "step": 0.1, + "description": "Multiplier for the interquartile range to define outliers." + } + ] + }, + { + "name": "ocsvm", + "path": "data.outlier_filtering_methods.ocsvm", + "full_name": "One-Class SVM", + "show_when": {"field": "data.outlier_filtering_method", "equals": "ocsvm"}, + "fields": [ + { + "name": "kernel", + "full_name": "Kernel", + "type": "select", + "default_value": "rbf", + "options": [ + {"value": "linear", "label": "linear"}, + {"value": "poly", "label": "poly"}, + {"value": "rbf", "label": "rbf"}, + {"value": "sigmoid", "label": "sigmoid"}, + {"value": "precomputed", "label": "precomputed"} + ], + "description": "Kernel type." + }, + { + "name": "degree", + "full_name": "Degree", + "type": "int", + "default_value": 3, + "min": 1, + "max": 10, + "step": 1, + "description": "Degree of the polynomial kernel (if kernel=poly)." + }, + { + "name": "gamma", + "full_name": "Gamma", + "type": "select", + "default_value": "scale", + "options": [ + {"value": "scale", "label": "scale"}, + {"value": "auto", "label": "auto"} + ], + "description": "Kernel coefficient for rbf/poly/sigmoid." + }, + { + "name": "coef0", + "full_name": "Coef0", + "type": "float64", + "default_value": 0.0, + "min": -10.0, + "max": 10.0, + "step": 0.1, + "description": "Independent term in kernel function." + }, + { + "name": "tol", + "full_name": "Tolerance", + "type": "float64", + "default_value": 0.001, + "min": 0.0, + "max": 1.0, + "step": 0.0001, + "description": "Stopping criterion tolerance." + }, + { + "name": "nu", + "full_name": "Nu", + "type": "float64", + "default_value": 0.005, + "min": 0.0, + "max": 1.0, + "step": 0.001, + "description": "Upper bound on margin errors / lower bound on support vectors." + }, + { + "name": "shrinking", + "full_name": "Shrinking", + "type": "bool", + "default_value": true, + "description": "Whether to use the shrinking heuristic." + }, + { + "name": "cache_size", + "full_name": "Cache size (MB)", + "type": "float64", + "default_value": 400.0, + "min": 1.0, + "max": 8192.0, + "step": 1.0, + "description": "Kernel cache size in MB." + } + ] + }, + { + "name": "isolation_forest", + "path": "data.outlier_filtering_methods.isolation_forest", + "full_name": "Isolation Forest", + "show_when": {"field": "data.outlier_filtering_method", "equals": "isolation_forest"}, + "fields": [ + { + "name": "n_estimators", + "full_name": "N estimators", + "type": "uint", + "default_value": 100, + "min": 1, + "max": 1000, + "step": 1, + "description": "Number of base estimators in the ensemble." + }, + { + "name": "max_samples", + "full_name": "Max samples", + "type": "select", + "default_value": "auto", + "options": [ + {"value": "auto", "label": "auto"} + ], + "description": "Samples drawn to train each base estimator." + }, + { + "name": "contamination", + "full_name": "Contamination", + "type": "float64", + "default_value": 0.01, + "min": 0.0, + "max": 0.5, + "step": 0.001, + "description": "Expected proportion of outliers." + }, + { + "name": "max_features", + "full_name": "Max features", + "type": "float64", + "default_value": 1.0, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "description": "Fraction of features drawn to train each base estimator." + }, + { + "name": "bootstrap", + "full_name": "Bootstrap", + "type": "bool", + "default_value": false, + "description": "Whether to use bootstrap sampling." + }, + { + "name": "n_jobs", + "full_name": "N jobs", + "type": "int", + "default_value": -1, + "min": -1, + "max": 64, + "step": 1, + "description": "Parallel jobs (-1 = all processors)." + } + ] + } + ] + } + ] }, - "bs": { - "full_name": "Batch Size", - "type": "uint", - "default_value": 512, - "min": 1, - "max": 1024, - "step": 1, - "description": "Number of samples processed before updating model weights." + { + "name": "model", + "path": "model", + "full_name": "Model", + "fields": [ + { + "name": "architecture", + "full_name": "Architecture", + "type": "select", + "default_value": "attention", + "options": [ + {"value": "mlp", "label": "MLP"}, + {"value": "ensemble", "label": "Ensemble"}, + {"value": "attention", "label": "Attention"}, + {"value": "attention_dann", "label": "Attention DANN"}, + {"value": "attention_cdan", "label": "Attention CDAN"}, + {"value": "boosting_lgbm", "label": "Boosting LightGBM"}, + {"value": "boosting_xgb", "label": "Boosting XGBoost"} + ], + "description": "Neural network / boosting architecture to train." + } + ], + "groups": [ + { + "name": "mlp", + "path": "model.mlp", + "full_name": "MLP", + "show_when": {"field": "model.architecture", "equals": "mlp"}, + "fields": [ + { + "name": "hidden_layers", + "full_name": "Hidden layers", + "type": "uint_list", + "default_value": [64, 32, 16], + "description": "Hidden layer sizes (comma-separated), e.g. 64, 32, 16." + }, + { + "name": "missing_data_strategy", + "full_name": "Missing data strategy", + "type": "select", + "default_value": "mean", + "options": [ + {"value": "mean", "label": "mean"}, + {"value": "linear regression", "label": "linear regression"} + ], + "description": "How to fill missing detector values for MLP." + }, + { + "name": "activation", + "full_name": "Activation", + "type": "select", + "default_value": "ReLU", + "options": [{"value": "ReLU", "label": "ReLU"}], + "description": "Non-linear activation after each layer." + }, + { + "name": "dropout", + "full_name": "Dropout", + "type": "float64", + "default_value": 0.9, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "description": "Dropout rate for regularisation." + } + ] + }, + { + "name": "ensemble", + "path": "model.ensemble", + "full_name": "Ensemble", + "show_when": {"field": "model.architecture", "equals": "ensemble"}, + "fields": [ + { + "name": "hidden_layers", + "full_name": "Hidden layers", + "type": "uint_list", + "default_value": [64, 32, 16], + "description": "Hidden layer sizes (comma-separated)." + }, + { + "name": "activation", + "full_name": "Activation", + "type": "select", + "default_value": "ReLU", + "options": [{"value": "ReLU", "label": "ReLU"}], + "description": "Non-linear activation after each layer." + }, + { + "name": "dropout", + "full_name": "Dropout", + "type": "float64", + "default_value": 0.9, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "description": "Dropout rate for regularisation." + } + ] + }, + { + "name": "attention", + "path": "model.attention", + "full_name": "Attention", + "show_when": {"field": "model.architecture", "equals": "attention"}, + "fields": [ + { + "name": "embed_hidden_layers", + "full_name": "Embed hidden layers", + "type": "uint_list", + "default_value": [64, 32, 16], + "description": "Hidden sizes for preliminary embedding MLP." + }, + { + "name": "embed_dim", + "full_name": "Embed dim", + "type": "uint", + "default_value": 32, + "min": 1, + "max": 4096, + "step": 1, + "description": "Embedding dimension." + }, + { + "name": "encoder_ff_hidden", + "full_name": "Encoder FF hidden", + "type": "uint", + "default_value": 128, + "min": 1, + "max": 8192, + "step": 1, + "description": "Feed-forward hidden size in attention encoder." + }, + { + "name": "mlp_hidden_layers", + "full_name": "MLP hidden layers", + "type": "uint_list", + "default_value": [64, 32, 16], + "description": "Hidden sizes for post-attention MLP." + }, + { + "name": "pool_hidden_layers", + "full_name": "Pool hidden layers", + "type": "uint_list", + "default_value": [64, 32, 16], + "description": "Hidden sizes for AttentionPooling module." + }, + { + "name": "num_heads", + "full_name": "Number of heads", + "type": "uint", + "default_value": 2, + "min": 1, + "max": 16, + "step": 1, + "description": "Number of heads in multi-head attention." + }, + { + "name": "num_blocks", + "full_name": "Number of blocks", + "type": "uint", + "default_value": 2, + "min": 1, + "max": 24, + "step": 1, + "description": "Number of attention blocks." + }, + { + "name": "activation", + "full_name": "Activation", + "type": "select", + "default_value": "ReLU", + "options": [{"value": "ReLU", "label": "ReLU"}], + "description": "Activation for feed-forward network." + }, + { + "name": "dropout", + "full_name": "Dropout", + "type": "float64", + "default_value": 0.4, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "description": "Dropout for feed-forward network." + } + ] + }, + { + "name": "attention_dann", + "path": "model.attention_dann", + "full_name": "Attention DANN", + "show_when": {"field": "model.architecture", "equals": "attention_dann"}, + "fields": [ + { + "name": "dom_hidden_layers", + "full_name": "Domain classifier hidden layers", + "type": "uint_list", + "default_value": [64, 32, 16], + "description": "Hidden sizes for domain classifier." + }, + { + "name": "alpha", + "full_name": "Alpha", + "type": "float64", + "default_value": 2.0, + "min": 0.0, + "max": 100.0, + "step": 0.1, + "description": "Domain-adversarial loss multiplier." + } + ], + "groups": [ + { + "name": "attention", + "path": "model.attention_dann.attention", + "full_name": "Attention backbone", + "fields": [ + { + "name": "embed_hidden_layers", + "full_name": "Embed hidden layers", + "type": "uint_list", + "default_value": [64, 32, 16], + "description": "Hidden sizes for preliminary embedding MLP." + }, + { + "name": "embed_dim", + "full_name": "Embed dim", + "type": "uint", + "default_value": 32, + "min": 1, + "max": 4096, + "step": 1, + "description": "Embedding dimension." + }, + { + "name": "encoder_ff_hidden", + "full_name": "Encoder FF hidden", + "type": "uint", + "default_value": 128, + "min": 1, + "max": 8192, + "step": 1, + "description": "Feed-forward hidden size in attention encoder." + }, + { + "name": "mlp_hidden_layers", + "full_name": "MLP hidden layers", + "type": "uint_list", + "default_value": [64, 32, 16], + "description": "Hidden sizes for post-attention MLP." + }, + { + "name": "pool_hidden_layers", + "full_name": "Pool hidden layers", + "type": "uint_list", + "default_value": [64, 32, 16], + "description": "Hidden sizes for AttentionPooling module." + }, + { + "name": "num_heads", + "full_name": "Number of heads", + "type": "uint", + "default_value": 2, + "min": 1, + "max": 16, + "step": 1, + "description": "Number of heads in multi-head attention." + }, + { + "name": "num_blocks", + "full_name": "Number of blocks", + "type": "uint", + "default_value": 2, + "min": 1, + "max": 24, + "step": 1, + "description": "Number of attention blocks." + }, + { + "name": "activation", + "full_name": "Activation", + "type": "select", + "default_value": "ReLU", + "options": [{"value": "ReLU", "label": "ReLU"}], + "description": "Activation for feed-forward network." + }, + { + "name": "dropout", + "full_name": "Dropout", + "type": "float64", + "default_value": 0.4, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "description": "Dropout for feed-forward network." + } + ] + } + ] + }, + { + "name": "attention_cdan", + "path": "model.attention_cdan", + "full_name": "Attention CDAN", + "show_when": {"field": "model.architecture", "equals": "attention_cdan"}, + "fields": [ + { + "name": "dom_hidden_layers", + "full_name": "Domain classifier hidden layers", + "type": "uint_list", + "default_value": [64, 32, 16], + "description": "Hidden sizes for domain classifier." + }, + { + "name": "alpha", + "full_name": "Alpha", + "type": "float64", + "default_value": 2.0, + "min": 0.0, + "max": 100.0, + "step": 0.1, + "description": "Domain-adversarial loss multiplier." + } + ], + "groups": [ + { + "name": "attention", + "path": "model.attention_cdan.attention", + "full_name": "Attention backbone", + "fields": [ + { + "name": "embed_hidden_layers", + "full_name": "Embed hidden layers", + "type": "uint_list", + "default_value": [64, 32, 16], + "description": "Hidden sizes for preliminary embedding MLP." + }, + { + "name": "embed_dim", + "full_name": "Embed dim", + "type": "uint", + "default_value": 32, + "min": 1, + "max": 4096, + "step": 1, + "description": "Embedding dimension." + }, + { + "name": "encoder_ff_hidden", + "full_name": "Encoder FF hidden", + "type": "uint", + "default_value": 128, + "min": 1, + "max": 8192, + "step": 1, + "description": "Feed-forward hidden size in attention encoder." + }, + { + "name": "mlp_hidden_layers", + "full_name": "MLP hidden layers", + "type": "uint_list", + "default_value": [64, 32, 16], + "description": "Hidden sizes for post-attention MLP." + }, + { + "name": "pool_hidden_layers", + "full_name": "Pool hidden layers", + "type": "uint_list", + "default_value": [64, 32, 16], + "description": "Hidden sizes for AttentionPooling module." + }, + { + "name": "num_heads", + "full_name": "Number of heads", + "type": "uint", + "default_value": 2, + "min": 1, + "max": 16, + "step": 1, + "description": "Number of heads in multi-head attention." + }, + { + "name": "num_blocks", + "full_name": "Number of blocks", + "type": "uint", + "default_value": 2, + "min": 1, + "max": 24, + "step": 1, + "description": "Number of attention blocks." + }, + { + "name": "activation", + "full_name": "Activation", + "type": "select", + "default_value": "ReLU", + "options": [{"value": "ReLU", "label": "ReLU"}], + "description": "Activation for feed-forward network." + }, + { + "name": "dropout", + "full_name": "Dropout", + "type": "float64", + "default_value": 0.4, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "description": "Dropout for feed-forward network." + } + ] + } + ] + }, + { + "name": "boosting", + "path": "model.boosting", + "full_name": "Boosting (GBDT)", + "show_when": {"field": "model.architecture", "equals": ["boosting_lgbm", "boosting_xgb"]}, + "fields": [ + { + "name": "group_by_missing", + "full_name": "Group by missing", + "type": "bool", + "default_value": true, + "description": "Group by missing column combinations." + }, + { + "name": "drop_TRD_data", + "full_name": "Drop TRD data", + "type": "bool", + "default_value": false, + "description": "Drop TRD columns when grouping by missing (avoids tiny groups)." + }, + { + "name": "n_trials", + "full_name": "Optuna trials", + "type": "uint", + "default_value": 50, + "min": 1, + "max": 1000, + "step": 1, + "description": "Number of Optuna hyperparameter search trials." + } + ], + "groups": [ + { + "name": "lgbm", + "path": "model.boosting.lgbm", + "full_name": "LightGBM Optuna ranges", + "show_when": {"field": "model.architecture", "equals": "boosting_lgbm"}, + "fields": [ + { + "name": "boosting_type", + "full_name": "Boosting types", + "type": "string_list", + "default_value": ["gbdt", "dart", "rf"], + "description": "Candidate boosting types for Optuna (comma-separated)." + }, + { + "name": "lambda_l1", + "full_name": "Lambda L1 range", + "type": "float_range", + "default_value": [1e-08, 0.5], + "min": 0.0, + "max": 10.0, + "step": 1e-08, + "description": "Optuna search range for L1 regularisation." + }, + { + "name": "lambda_l2", + "full_name": "Lambda L2 range", + "type": "float_range", + "default_value": [1e-08, 9.0], + "min": 0.0, + "max": 100.0, + "step": 1e-08, + "description": "Optuna search range for L2 regularisation." + }, + { + "name": "num_leaves", + "full_name": "Num leaves range", + "type": "int_range", + "default_value": [30, 300], + "min": 2, + "max": 1024, + "step": 1, + "description": "Optuna search range for number of leaves." + }, + { + "name": "feature_fraction", + "full_name": "Feature fraction range", + "type": "float_range", + "default_value": [0.3, 1.0], + "min": 0.0, + "max": 1.0, + "step": 0.01, + "description": "Optuna search range for feature fraction." + }, + { + "name": "bagging_fraction", + "full_name": "Bagging fraction range", + "type": "float_range", + "default_value": [0.3, 1.0], + "min": 0.0, + "max": 1.0, + "step": 0.01, + "description": "Optuna search range for bagging fraction." + }, + { + "name": "bagging_freq", + "full_name": "Bagging freq range", + "type": "float_range", + "default_value": [1, 10], + "min": 0, + "max": 100, + "step": 1, + "description": "Optuna search range for bagging frequency." + }, + { + "name": "min_child_samples", + "full_name": "Min child samples range", + "type": "int_range", + "default_value": [5, 100], + "min": 1, + "max": 1000, + "step": 1, + "description": "Optuna search range for min child samples." + }, + { + "name": "learning_rate", + "full_name": "Learning rate range", + "type": "float_range", + "default_value": [1e-08, 0.2], + "min": 0.0, + "max": 1.0, + "step": 1e-08, + "description": "Optuna search range for learning rate." + } + ] + }, + { + "name": "xgb", + "path": "model.boosting.xgb", + "full_name": "XGBoost Optuna ranges", + "show_when": {"field": "model.architecture", "equals": "boosting_xgb"}, + "fields": [ + { + "name": "max_depth", + "full_name": "Max depth range", + "type": "int_range", + "default_value": [4, 10], + "min": 1, + "max": 32, + "step": 1, + "description": "Optuna search range for max tree depth." + }, + { + "name": "learning_rate", + "full_name": "Learning rate range", + "type": "float_range", + "default_value": [0.001, 0.175], + "min": 0.0, + "max": 1.0, + "step": 0.001, + "description": "Optuna search range for learning rate." + }, + { + "name": "n_estimators", + "full_name": "N estimators range", + "type": "int_range", + "default_value": [100, 1000], + "min": 1, + "max": 5000, + "step": 1, + "description": "Optuna search range for number of estimators." + }, + { + "name": "subsample", + "full_name": "Subsample range", + "type": "float_range", + "default_value": [0.5, 1.0], + "min": 0.0, + "max": 1.0, + "step": 0.01, + "description": "Optuna search range for subsample ratio." + }, + { + "name": "colsample_bytree", + "full_name": "Colsample by tree range", + "type": "float_range", + "default_value": [0.5, 1.0], + "min": 0.0, + "max": 1.0, + "step": 0.01, + "description": "Optuna search range for column subsample." + }, + { + "name": "min_child_weight", + "full_name": "Min child weight range", + "type": "float_range", + "default_value": [1, 10], + "min": 0.0, + "max": 100.0, + "step": 0.1, + "description": "Optuna search range for min child weight." + }, + { + "name": "gamma", + "full_name": "Gamma range", + "type": "float_range", + "default_value": [0.001, 5.0], + "min": 0.0, + "max": 100.0, + "step": 0.001, + "description": "Optuna search range for min loss reduction (gamma)." + } + ] + } + ] + } + ] }, - "max_epochs": { - "full_name": "Maximum Epochs", - "type": "uint", - "default_value": 40, - "min": 1, - "max": 1000, - "step": 1, - "description": "Total number of training cycles through the dataset." + { + "name": "training", + "path": "training", + "full_name": "Training", + "fields": [ + { + "name": "optimizer", + "full_name": "Optimizer", + "type": "select", + "default_value": "adamw", + "options": [ + {"value": "adamw", "label": "AdamW"}, + {"value": "sgd", "label": "SGD"} + ], + "description": "Optimizer used for training." + }, + { + "name": "lr_scheduler", + "full_name": "LR scheduler", + "type": "select", + "default_value": "polynomial", + "allow_null": true, + "options": [ + {"value": null, "label": "None"}, + {"value": "exponential", "label": "Exponential"}, + {"value": "cosine_restarts", "label": "Cosine restarts"}, + {"value": "polynomial", "label": "Polynomial"}, + {"value": "constant", "label": "Constant"} + ], + "description": "Learning rate scheduler (optional)." + }, + { + "name": "start_lr", + "full_name": "Starting learning rate", + "type": "float64", + "default_value": 0.003, + "min": 0.000001, + "max": 1.0, + "step": 0.000001, + "description": "Initial learning rate." + }, + { + "name": "batch_size", + "full_name": "Batch size", + "type": "uint", + "default_value": 8192, + "min": 1, + "max": 65536, + "step": 1, + "description": "Training batch size." + }, + { + "name": "max_epochs", + "full_name": "Maximum epochs", + "type": "uint", + "default_value": 200, + "min": 1, + "max": 1000, + "step": 1, + "description": "Max training epochs if early stopping does not trigger." + }, + { + "name": "early_stopping_epoch_count", + "full_name": "Early stopping patience", + "type": "uint", + "default_value": 5, + "min": 1, + "max": 100, + "step": 1, + "description": "Epochs without progress before early stopping." + }, + { + "name": "early_stopping_progress_threshold", + "full_name": "Early stopping threshold", + "type": "float64", + "default_value": 0.001, + "min": 0.0, + "max": 1.0, + "step": 0.0001, + "description": "Minimum relative loss improvement to reset patience." + }, + { + "name": "weight_particles_species", + "full_name": "Weight particle species", + "type": "bool", + "default_value": false, + "description": "Weight loss by particle-species class imbalance." + }, + { + "name": "undersample_missing_detectors", + "full_name": "Undersample missing detectors", + "type": "bool", + "default_value": true, + "description": "Undersample by missing-detector groups each epoch." + }, + { + "name": "undersample_pions", + "full_name": "Undersample pions", + "type": "bool", + "default_value": false, + "description": "Within each missing-detector group, undersample (anti)pions to next majority class." + } + ], + "groups": [ + { + "name": "optimizers", + "path": "training.optimizers", + "full_name": "Optimizer parameters", + "groups": [ + { + "name": "adamw", + "path": "training.optimizers.adamw", + "full_name": "AdamW", + "show_when": {"field": "training.optimizer", "equals": "adamw"}, + "fields": [ + { + "name": "weight_decay", + "full_name": "Weight decay", + "type": "float64", + "default_value": 0.0, + "min": 0.0, + "max": 1.0, + "step": 0.0001, + "description": "AdamW weight decay regularisation." + } + ] + }, + { + "name": "sgd", + "path": "training.optimizers.sgd", + "full_name": "SGD", + "show_when": {"field": "training.optimizer", "equals": "sgd"}, + "fields": [ + { + "name": "momentum", + "full_name": "Momentum", + "type": "float64", + "default_value": 0.9, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "description": "SGD momentum." + }, + { + "name": "weight_decay", + "full_name": "Weight decay", + "type": "float64", + "default_value": 0.0, + "min": 0.0, + "max": 1.0, + "step": 0.0001, + "description": "SGD weight decay." + }, + { + "name": "nesterov", + "full_name": "Nesterov", + "type": "bool", + "default_value": true, + "description": "Use Nesterov momentum." + } + ] + } + ] + }, + { + "name": "lr_schedulers", + "path": "training.lr_schedulers", + "full_name": "LR scheduler parameters", + "groups": [ + { + "name": "exponential", + "path": "training.lr_schedulers.exponential", + "full_name": "Exponential", + "show_when": {"field": "training.lr_scheduler", "equals": "exponential"}, + "fields": [ + { + "name": "gamma", + "full_name": "Gamma", + "type": "float64", + "default_value": 0.9, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "description": "Multiplicative LR decay factor per epoch." + } + ] + }, + { + "name": "cosine_restarts", + "path": "training.lr_schedulers.cosine_restarts", + "full_name": "Cosine restarts", + "show_when": {"field": "training.lr_scheduler", "equals": "cosine_restarts"}, + "fields": [ + { + "name": "first_cycle_epochs", + "full_name": "First cycle epochs", + "type": "uint", + "default_value": 20, + "min": 1, + "max": 1000, + "step": 1, + "description": "Epochs in the first cosine cycle." + }, + { + "name": "cycle_epoch_inc", + "full_name": "Cycle epoch increment", + "type": "uint", + "default_value": 5, + "min": 0, + "max": 1000, + "step": 1, + "description": "Extra epochs added to each new cycle." + } + ] + }, + { + "name": "polynomial", + "path": "training.lr_schedulers.polynomial", + "full_name": "Polynomial", + "show_when": {"field": "training.lr_scheduler", "equals": "polynomial"}, + "fields": [ + { + "name": "power", + "full_name": "Power", + "type": "float64", + "default_value": 1.2, + "min": 0.0, + "max": 10.0, + "step": 0.1, + "description": "Polynomial decay power." + } + ] + }, + { + "name": "constant", + "path": "training.lr_schedulers.constant", + "full_name": "Constant", + "show_when": {"field": "training.lr_scheduler", "equals": "constant"}, + "fields": [ + { + "name": "factor", + "full_name": "Factor", + "type": "float64", + "default_value": 1.0, + "min": 0.0, + "max": 10.0, + "step": 0.01, + "description": "Constant LR multiplicative factor." + }, + { + "name": "total_iters", + "full_name": "Total iters", + "type": "uint", + "default_value": 50, + "min": 1, + "max": 10000, + "step": 1, + "description": "Epochs for which factor is applied." + } + ] + } + ] + } + ] }, - "dropout": { - "full_name": "Dropout Rate", - "type": "float64", - "default_value": 0.1, - "min": 0.0, - "max": 1.0, - "step": 0.01, - "description": "Fraction of neurons randomly deactivated to prevent overfitting." + { + "name": "validation", + "path": "validation", + "full_name": "Validation", + "fields": [ + { + "name": "validate_every", + "full_name": "Validate every N epochs", + "type": "uint", + "default_value": 3, + "min": 1, + "max": 100, + "step": 1, + "description": "Run validation after this many training epochs." + } + ] }, - "gamma": { - "full_name": "Gamma", - "type": "float64", - "default_value": 0.9, - "min": 0.0, - "max": 1.0, - "step": 0.01, - "description": "Learning rate decay factor applied after each epoch." - }, - "patience": { - "full_name": "Patience", - "type": "uint", - "default_value": 5, - "min": 1, - "max": 100, - "step": 1, - "description": "Number of epochs to wait for improvement before early stopping." - }, - "patience_threshold": { - "full_name": "Patience Threshold", - "type": "float64", - "default_value": 0.001, - "min": 0.0, - "max": 1.0, - "step": 0.0001, - "description": "Minimum improvement threshold to reset patience." - }, - "embed_hidden": { - "full_name": "Embedding Hidden Size", - "type": "uint", - "default_value": 128, - "min": 1, - "max": 4096, - "step": 1, - "description": "Number of neurons in the embedding hidden layer." - }, - "d_model": { - "full_name": "D Model", - "type": "uint", - "default_value": 32, - "min": 1, - "max": 4096, - "step": 1, - "description": "Dimensionality of model hidden states." - }, - "ff_hidden": { - "full_name": "Feedforward Hidden Size", - "type": "uint", - "default_value": 128, - "min": 1, - "max": 8192, - "step": 1, - "description": "Size of hidden layer in the feedforward network." - }, - "pool_hidden": { - "full_name": "Pooling Hidden Size", - "type": "uint", - "default_value": 64, - "min": 1, - "max": 4096, - "step": 1, - "description": "Number of neurons in the pooling hidden layer." - }, - "num_heads": { - "full_name": "Number of Attention Heads", - "type": "uint", - "default_value": 2, - "min": 1, - "max": 16, - "step": 1, - "description": "Number of attention heads in the model." - }, - "num_blocks": { - "full_name": "Number of Blocks", - "type": "uint", - "default_value": 2, - "min": 1, - "max": 24, - "step": 1, - "description": "Total number of blocks in the model." - }, - "start_lr": { - "full_name": "Starting Learning Rate", - "type": "float64", - "default_value": 0.0001, - "min": 0.0001, - "max": 1.0, - "step": 0.00001, - "description": "Initial learning rate for model training." + { + "name": "seed", + "path": "", + "full_name": "General", + "fields": [ + { + "name": "seed", + "full_name": "Seed", + "type": "int", + "default_value": 0, + "min": 0, + "max": 2147483647, + "step": 1, + "description": "Random seed for reproducibility." + } + ] } - } + ] } diff --git a/web/templates/partials/aod-content.html b/web/templates/partials/aod-content.html new file mode 100644 index 0000000..15c56d4 --- /dev/null +++ b/web/templates/partials/aod-content.html @@ -0,0 +1,26 @@ +{{ define "aod_period_content" }} +{{ if . }} +
+ {{ range . }} +
+
+ {{ .LHCPeriod }} + {{ .FileCount }} {{ if eq .FileCount 1 }}file{{ else }}files{{ end }} + {{ formatFileSizePretty .TotalSize }} +
+
+ {{ range .Runs }} +
+ Run {{ .RunNumber }} + {{ .FileCount }} {{ if eq .FileCount 1 }}file{{ else }}files{{ end }} + {{ formatFileSizePretty .TotalSize }} +
+ {{ end }} +
+
+ {{ end }} +
+{{ else }} +

No AOD files in this dataset.

+{{ end }} +{{ end }} diff --git a/web/templates/partials/nn-config.html b/web/templates/partials/nn-config.html new file mode 100644 index 0000000..d8db3c9 --- /dev/null +++ b/web/templates/partials/nn-config.html @@ -0,0 +1,75 @@ +{{ define "nn_field_group" }} +
+

{{ .FullName }}

+ {{ range .Fields }} + {{ $fieldName := configFieldName $.Path .Name }} + {{ $configPath := configPath $.Path .Name }} +
+
+ + + {{ if eq .Type "uint" "int" }} + + + {{ else if eq .Type "float64" }} + + + {{ else if eq .Type "bool" }} + + + + {{ else if eq .Type "select" }} + + + {{ else if eq .Type "uint_list" "string_list" }} + + + {{ else if eq .Type "float_range" "int_range" }} +
+ + – + +
+ + {{ else }} + + {{ end }} +
+
+

{{ .Description }}

+
+
+ {{ end }} + {{ range .Groups }} + {{ template "nn_field_group" . }} + {{ end }} +
+{{ end }} diff --git a/web/templates/training-datasets/list.html b/web/templates/training-datasets/list.html index 906385c..a02695b 100644 --- a/web/templates/training-datasets/list.html +++ b/web/templates/training-datasets/list.html @@ -1,33 +1,27 @@ {{ define "training-datasets_list" }} {{ range .TrainingDatasets }}
-
- -
{{ .Name }}
-
- -
-
- {{ range .AODFiles }} -
-
- - - -

-

{{ .LHCPeriod }}

-

{{ .RunNumber }}

-

{{ - .AODNumber }}

-

-
-

{{ formatFileSizePretty .Size }}

-
+ class="flex flex-wrap w-full items-center justify-between gap-3 bg-sky-50 dark:bg-sky-900 hover:bg-white dark:hover:bg-sky-800 rounded-lg py-3 px-4"> + + {{ .Name }} + +
+ {{ range summarizeAODByPeriod .AODFiles }} + + {{ .LHCPeriod }} + {{ .FileCount }} {{ if eq .FileCount 1 }}file{{ else }}files{{ end }} + {{ formatFileSizePretty .TotalSize }} + + {{ else }} + No AOD files {{ end }} + + by {{ .User.FirstName }} {{ .User.FamilyName }} ({{ .User.Username }}) +
+
{{ end }} -{{ end }} \ No newline at end of file +{{ end }} diff --git a/web/templates/training-datasets/new.html b/web/templates/training-datasets/new.html index 42d9fbf..9cdede7 100644 --- a/web/templates/training-datasets/new.html +++ b/web/templates/training-datasets/new.html @@ -92,6 +92,27 @@

Choose random subset

+ +
@@ -269,4 +290,4 @@

Selected f } {{ template "footer" . }} -{{ end }} \ No newline at end of file +{{ end }} diff --git a/web/templates/training-datasets/show.html b/web/templates/training-datasets/show.html index 1836a2f..a87541b 100644 --- a/web/templates/training-datasets/show.html +++ b/web/templates/training-datasets/show.html @@ -2,30 +2,15 @@ {{ template "header" . }}

{{ .TrainingDataset.Name }}

-
+

Created by:

-

{{ .TrainingDataset.User.FirstName }} {{ .TrainingDataset.User.FamilyName }} ({{ .TrainingDataset.User.Username }})

-

Files:

-
- {{ range .TrainingDataset.AODFiles }} -
-
- - - -

-

{{ .LHCPeriod }}

-

{{ .RunNumber }}

-

{{ - .AODNumber }}

-

-
-

{{ formatFileSizePretty .Size }}

-
- {{ end }} +

{{ .TrainingDataset.User.FirstName }} {{ .TrainingDataset.User.FamilyName }} ({{ .TrainingDataset.User.Username }})

+ +

Content:

+
+ {{ template "aod_period_content" (summarizeAODByPeriodAndRun .TrainingDataset.AODFiles) }}
+

Created at:

{{ .TrainingDataset.CreatedAt.Format "02 Jan 06 15:04 MST" }}

Last update at:

@@ -33,4 +18,4 @@

{{ .TrainingDataset.UpdatedAt.Format "02 Jan 06 15:04 MST" }}

{{ template "footer" . }} -{{ end }} \ No newline at end of file +{{ end }} diff --git a/web/templates/training-tasks/new.html b/web/templates/training-tasks/new.html index ef5103b..e8bb30a 100644 --- a/web/templates/training-tasks/new.html +++ b/web/templates/training-tasks/new.html @@ -1,12 +1,13 @@ {{ define "training-tasks_new" }} {{ template "header" . }}
-
+ hx-ext="json-enc" + data-prefill-dataset-id="{{ if .PrefillDatasetId }}{{ .PrefillDatasetId }}{{ end }}">

Create Training Task

-
+

Basic configuration:

Basic configuration:

- +
@@ -24,47 +26,26 @@

Basic configuration:

Additional configuration:

-
- {{range $field, $spec := .FieldConfigs}} -
-
- - - {{if eq $spec.Type "uint" "int"}} - - {{else if eq $spec.Type "float64"}} - - {{else if eq $spec.Type "bool"}} - - {{else}} - - {{end}} -
-
-

{{$spec.Description}}

-
-
- {{end}} +
+ {{ range .FieldGroups }} + {{ template "nn_field_group" . }} + {{ end }}
+ + {{ template "footer" . }} -{{ end }} \ No newline at end of file +{{ end }} diff --git a/web/templates/training-tasks/show.html b/web/templates/training-tasks/show.html index 2eb7270..9044870 100644 --- a/web/templates/training-tasks/show.html +++ b/web/templates/training-tasks/show.html @@ -1,6 +1,6 @@ {{ define "training-tasks_show" }} {{ template "header" . }} -
+

{{ .TrainingTask.Name }}

Status:

@@ -55,43 +55,57 @@

Log Files

{{ end }} -

Training dataset - {{ .TrainingTask.TrainingDataset.Name }}

-
-
- {{ range .TrainingTask.TrainingDataset.AODFiles }} -
-
- - - -

-

{{ .LHCPeriod }}

-

{{ .RunNumber }}

-

{{ - .AODNumber }}

-

-
-

{{ formatFileSizePretty .Size }}

-
- {{ end }} -
+

Training dataset — {{ .TrainingTask.TrainingDataset.Name }}

+
+ {{ template "aod_period_content" (summarizeAODByPeriodAndRun .TrainingTask.TrainingDataset.AODFiles) }}
+

Created by:

{{ .TrainingTask.User.FirstName }} {{ .TrainingTask.User.FamilyName }} ({{ .TrainingTask.User.Username }})

-

Configuration:

-
- {{ range $key, $value := .TrainingTask.Configuration }} -

{{ $key }}: {{ $value }}

- {{ end }} -

Created at:

{{ .TrainingTask.CreatedAt.Format "02 Jan 06 15:04 MST" }}

Last update at:

{{ .TrainingTask.UpdatedAt.Format "02 Jan 06 15:04 MST" }}

+ +
+
+

Configuration

+
+ + + Reuse in new task + +
+
+
+ {{ $pretty := formatConfigPretty .TrainingTask.Configuration }} + {{ if $pretty }} +
{{ $pretty }}
+ {{ else }} +

No configuration stored.

+ {{ end }} +
+ +
+ + + + + + {{ template "footer" . }} -{{ end }} \ No newline at end of file +{{ end }}