chore: auto-commit (95 archivos)

- cmd/fn/doctor.go
- cmd/fn/main.go
- cpp/apps/primitives_gallery/playground/tables/CMakeLists.txt
- cpp/apps/primitives_gallery/playground/tables/data_table.cpp
- cpp/apps/primitives_gallery/playground/tables/data_table_logic.cpp
- cpp/apps/primitives_gallery/playground/tables/data_table_logic.h
- cpp/apps/primitives_gallery/playground/tables/self_test.cpp
- cpp/apps/primitives_gallery/playground/tables/tql.cpp
- cpp/apps/primitives_gallery/playground/tables/viz.cpp
- cpp/apps/primitives_gallery/playground/tables/viz.h
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 00:50:34 +02:00
parent ef60449e64
commit a802f59f55
189 changed files with 18964 additions and 330 deletions
+20
View File
@@ -0,0 +1,20 @@
package ml
import "encoding/json"
// GenconfigMarshal serializa un GenerationConfig a JSON canonico con indent de 2 espacios.
// El formato es identico al de Python json.dumps(indent=2, sort_keys=False):
// keys en el orden de declaracion del struct, snake_case, campos omitempty ausentes si zero.
func GenconfigMarshal(cfg GenerationConfig) ([]byte, error) {
return json.MarshalIndent(cfg, "", " ")
}
// GenconfigUnmarshal deserializa JSON (compacto o con indent) a GenerationConfig.
// Los campos JSON deben usar snake_case: negative_prompt, cfg_scale, model_type, etc.
func GenconfigUnmarshal(data []byte) (GenerationConfig, error) {
var cfg GenerationConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return GenerationConfig{}, err
}
return cfg, nil
}
+84
View File
@@ -0,0 +1,84 @@
---
name: genconfig_json_marshal
kind: function
lang: go
domain: ml
version: "1.0.0"
purity: impure
signature: "func GenconfigMarshal(cfg GenerationConfig) ([]byte, error)\nfunc GenconfigUnmarshal(data []byte) (GenerationConfig, error)"
description: "Wrappers json.Marshal/Unmarshal para GenerationConfig con formato canonico (MarshalIndent 2 espacios). Garantiza roundtrip identico al Python: json.dumps(indent=2, sort_keys=False). Campos JSON en snake_case."
tags: [ml, json, marshal, unmarshal, serialization, generation, canonical]
uses_functions: []
uses_types: [generation_config_go_ml]
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["encoding/json"]
params:
- name: cfg
desc: "GenerationConfig a serializar. Campos omitempty (negative_prompt, loras, clip_skip) se omiten si son zero/nil/empty."
- name: data
desc: "JSON bytes a deserializar. Acepta formato compacto o con indent. Keys deben ser snake_case (negative_prompt, cfg_scale, model_type, etc.)."
output: "GenconfigMarshal: bytes JSON con indent 2 espacios, orden de campos segun declaracion del struct (prompt, negative_prompt, seed, steps, cfg_scale, sampler, width, height, model, loras, clip_skip). GenconfigUnmarshal: GenerationConfig poblado o error de parsing."
tested: true
tests:
- "roundtrip marshal unmarshal produce config igual"
- "json cross-language snake_case keys se deserializan correctamente"
test_file_path: "functions/ml/genconfig_test.go"
file_path: "functions/ml/genconfig_json_marshal.go"
---
## Ejemplo
```go
cfg := ml.GenerationConfig{
Prompt: "a mountain at sunset",
Seed: 1234,
Steps: 30,
CfgScale: 7.0,
Sampler: "euler",
Width: 768,
Height: 512,
Model: ml.ModelRef{Name: "sdxl-base", ModelType: "sdxl", Quantization: "fp16"},
}
b, err := ml.GenconfigMarshal(cfg)
// b == {
// "prompt": "a mountain at sunset",
// "seed": 1234,
// ...
// }
cfg2, err := ml.GenconfigUnmarshal(b)
// cfg2 == cfg (DeepEqual)
```
## Notas
### Formato canonico y compatibilidad con Python
`GenconfigMarshal` usa `json.MarshalIndent(cfg, "", " ")`. El formato resultante es identico al que produce Python con `model.model_dump_json()` o `json.dumps(data, indent=2)` cuando `sort_keys=False`:
- Keys en orden de declaracion del struct (no alfabetico).
- Indent de 2 espacios, sin trailing whitespace.
- Campos omitempty ausentes si zero: `negative_prompt` ausente si `""`, `loras` ausente si `[]`, `clip_skip` ausente si `nil`.
### Keys JSON (snake_case obligatorio)
| Campo Go | Key JSON |
|---|---|
| `Prompt` | `"prompt"` |
| `NegativePrompt` | `"negative_prompt"` |
| `Seed` | `"seed"` |
| `Steps` | `"steps"` |
| `CfgScale` | `"cfg_scale"` |
| `Sampler` | `"sampler"` |
| `Width` | `"width"` |
| `Height` | `"height"` |
| `Model.ModelType` | `"model_type"` |
| `Model.Quantization` | `"quantization"` |
| `ClipSkip` | `"clip_skip"` |
### Por que impure
Los errores de `json.Unmarshal` son errores de parsing del input externo, no de I/O, pero se modelan como `(T, error)` para forzar manejo explicito en el caller. Marcado `impure` con `error_type: error_go_core` por convencion del registry.
+260
View File
@@ -0,0 +1,260 @@
package ml
import (
"reflect"
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// TestGenconfigToSdcliArgs
// ---------------------------------------------------------------------------
func TestGenconfigToSdcliArgs(t *testing.T) {
clipSkip := 2
t.Run("config basico sin loras ni clip_skip", func(t *testing.T) {
cfg := GenerationConfig{
Prompt: "a cat",
Seed: 42,
Steps: 20,
CfgScale: 7.5,
Sampler: "euler",
Width: 512,
Height: 512,
Model: ModelRef{Name: "v1-5", ModelType: "sd15", Quantization: "fp16"},
}
args := GenconfigToSdcliArgs(cfg)
want := []string{
"--prompt", "a cat",
"--seed", "42",
"--steps", "20",
"--cfg-scale", "7.5",
"--width", "512",
"--height", "512",
"--sampling-method", "euler",
}
if !reflect.DeepEqual(args, want) {
t.Errorf("got %v\nwant %v", args, want)
}
})
t.Run("loras se emiten como pares path:weight", func(t *testing.T) {
cfg := GenerationConfig{
Prompt: "portrait",
Seed: 1,
Steps: 10,
CfgScale: 7.0,
Sampler: "euler",
Width: 512,
Height: 512,
Model: ModelRef{Name: "v1-5", ModelType: "sd15", Quantization: "fp16", Path: "/models/v1.safetensors"},
Loras: []LoraRef{
{Path: "/loras/detail.safetensors", Weight: 0.8},
{Path: "/loras/style.safetensors", Weight: 0.5},
},
ClipSkip: &clipSkip,
}
args := GenconfigToSdcliArgs(cfg)
// Verificar que existen los pares --lora para ambas loras
loraIdx := indexAll(args, "--lora")
if len(loraIdx) != 2 {
t.Fatalf("esperaba 2 flags --lora, got %d en %v", len(loraIdx), args)
}
wantLoras := []string{
"/loras/detail.safetensors:0.8",
"/loras/style.safetensors:0.5",
}
for i, idx := range loraIdx {
if idx+1 >= len(args) {
t.Fatalf("--lora[%d] sin valor siguiente", i)
}
if args[idx+1] != wantLoras[i] {
t.Errorf("lora[%d]: got %q, want %q", i, args[idx+1], wantLoras[i])
}
}
// Verificar --model y --clip-skip presentes
if !containsPair(args, "--model", "/models/v1.safetensors") {
t.Errorf("--model no encontrado en %v", args)
}
if !containsPair(args, "--clip-skip", "2") {
t.Errorf("--clip-skip no encontrado en %v", args)
}
})
t.Run("sampler dpm++2m se traduce a dpmpp2m", func(t *testing.T) {
cfg := GenerationConfig{
Prompt: "x",
Seed: 0,
Steps: 1,
CfgScale: 1.0,
Sampler: "dpm++2m",
Width: 64,
Height: 64,
Model: ModelRef{Name: "m", ModelType: "sd15", Quantization: "fp16"},
}
args := GenconfigToSdcliArgs(cfg)
if !containsPair(args, "--sampling-method", "dpmpp2m") {
t.Errorf("sampler no traducido; args=%v", args)
}
})
t.Run("negative_prompt vacio no genera flag", func(t *testing.T) {
cfg := GenerationConfig{
Prompt: "x",
NegativePrompt: "",
Seed: 0,
Steps: 1,
CfgScale: 1.0,
Sampler: "euler",
Width: 64,
Height: 64,
Model: ModelRef{Name: "m", ModelType: "sd15", Quantization: "fp16"},
}
args := GenconfigToSdcliArgs(cfg)
for _, a := range args {
if a == "--negative-prompt" {
t.Errorf("flag --negative-prompt presente aunque NegativePrompt es vacio")
}
}
})
}
// ---------------------------------------------------------------------------
// TestGenconfigMarshalRoundtrip
// ---------------------------------------------------------------------------
func TestGenconfigMarshalRoundtrip(t *testing.T) {
t.Run("roundtrip marshal unmarshal produce config igual", func(t *testing.T) {
clip := 2
cfg := GenerationConfig{
Prompt: "sunset over the mountains",
NegativePrompt: "blurry, low quality",
Seed: 99,
Steps: 30,
CfgScale: 7.5,
Sampler: "dpm++2m",
Width: 768,
Height: 512,
Model: ModelRef{
Name: "sdxl-base",
ModelType: "sdxl",
Quantization: "fp16",
Path: "/models/sdxl.safetensors",
},
Loras: []LoraRef{
{Path: "/loras/detail.safetensors", Weight: 0.8},
},
ClipSkip: &clip,
}
b, err := GenconfigMarshal(cfg)
if err != nil {
t.Fatalf("GenconfigMarshal: %v", err)
}
got, err := GenconfigUnmarshal(b)
if err != nil {
t.Fatalf("GenconfigUnmarshal: %v", err)
}
if !reflect.DeepEqual(cfg, got) {
t.Errorf("roundtrip diverge\norig: %+v\ngot: %+v", cfg, got)
}
})
}
// ---------------------------------------------------------------------------
// TestGenconfigCrossLanguageJSON
// ---------------------------------------------------------------------------
func TestGenconfigCrossLanguageJSON(t *testing.T) {
// Fixture escrito a mano replicando lo que generaria Python:
// json.dumps(config.model_dump(), indent=2)
// Keys en snake_case, orden de declaracion del dataclass Python.
fixture := `{
"prompt": "a dragon",
"negative_prompt": "ugly",
"seed": 1234,
"steps": 25,
"cfg_scale": 7.0,
"sampler": "euler_a",
"width": 512,
"height": 512,
"model": {
"name": "v1-5",
"model_type": "sd15",
"quantization": "fp16"
},
"loras": [
{
"path": "/loras/dragon.safetensors",
"weight": 0.9
}
]
}`
t.Run("json cross-language snake_case keys se deserializan correctamente", func(t *testing.T) {
cfg, err := GenconfigUnmarshal([]byte(fixture))
if err != nil {
t.Fatalf("GenconfigUnmarshal fixture: %v", err)
}
// Verificar campos clave
if cfg.Prompt != "a dragon" {
t.Errorf("Prompt: got %q", cfg.Prompt)
}
if cfg.NegativePrompt != "ugly" {
t.Errorf("NegativePrompt: got %q", cfg.NegativePrompt)
}
if cfg.CfgScale != 7.0 {
t.Errorf("CfgScale: got %v", cfg.CfgScale)
}
if cfg.Model.ModelType != "sd15" {
t.Errorf("Model.ModelType: got %q", cfg.Model.ModelType)
}
if len(cfg.Loras) != 1 || cfg.Loras[0].Weight != 0.9 {
t.Errorf("Loras: got %+v", cfg.Loras)
}
// Re-marshal y verificar que las keys snake_case siguen presentes
b, err := GenconfigMarshal(cfg)
if err != nil {
t.Fatalf("GenconfigMarshal: %v", err)
}
s := string(b)
for _, key := range []string{"negative_prompt", "cfg_scale", "model_type", "quantization"} {
if !strings.Contains(s, `"`+key+`"`) {
t.Errorf("key %q ausente en JSON re-serializado:\n%s", key, s)
}
}
})
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
// indexAll retorna todos los indices de val en slice.
func indexAll(slice []string, val string) []int {
var out []int
for i, s := range slice {
if s == val {
out = append(out, i)
}
}
return out
}
// containsPair verifica que flag seguido de value aparece en slice.
func containsPair(slice []string, flag, value string) bool {
for i := 0; i+1 < len(slice); i++ {
if slice[i] == flag && slice[i+1] == value {
return true
}
}
return false
}
+59
View File
@@ -0,0 +1,59 @@
package ml
import (
"fmt"
"strconv"
)
// samplerMap traduce nombres canonicos del dominio ml a flags de stable-diffusion.cpp.
var samplerMap = map[string]string{
"euler": "euler",
"euler_a": "euler_a",
"dpm++2m": "dpmpp2m",
"dpm++2m_v2": "dpmpp2mv2",
"heun": "heun",
"dpm2": "dpm2",
"lcm": "lcm",
}
// GenconfigToSdcliArgs convierte un GenerationConfig en una lista de argumentos
// CLI para stable-diffusion.cpp (sd.exe / sd binario).
// Espejo Go de genconfig_to_sdcpp_args_py_ml.
//
// Loras se emiten como pares repetidos "--lora" "path:weight".
// Si el sampler no existe en samplerMap se usa el valor literal sin traducir.
// La funcion es pura: sin I/O, sin estado, determinista.
func GenconfigToSdcliArgs(cfg GenerationConfig) []string {
args := []string{
"--prompt", cfg.Prompt,
"--seed", strconv.FormatInt(cfg.Seed, 10),
"--steps", strconv.Itoa(cfg.Steps),
"--cfg-scale", strconv.FormatFloat(cfg.CfgScale, 'f', -1, 64),
"--width", strconv.Itoa(cfg.Width),
"--height", strconv.Itoa(cfg.Height),
}
if cfg.NegativePrompt != "" {
args = append(args, "--negative-prompt", cfg.NegativePrompt)
}
sampler := cfg.Sampler
if mapped, ok := samplerMap[sampler]; ok {
sampler = mapped
}
args = append(args, "--sampling-method", sampler)
if cfg.Model.Path != "" {
args = append(args, "--model", cfg.Model.Path)
}
if cfg.ClipSkip != nil {
args = append(args, "--clip-skip", strconv.Itoa(*cfg.ClipSkip))
}
for _, lora := range cfg.Loras {
args = append(args, "--lora", fmt.Sprintf("%s:%g", lora.Path, lora.Weight))
}
return args
}
+59
View File
@@ -0,0 +1,59 @@
---
name: genconfig_to_sdcli_args
kind: function
lang: go
domain: ml
version: "1.0.0"
purity: pure
signature: "func GenconfigToSdcliArgs(cfg GenerationConfig) []string"
description: "Convierte un GenerationConfig en argumentos CLI para stable-diffusion.cpp. Espejo Go de genconfig_to_sdcpp_args_py_ml. Loras se emiten como pares repetidos --lora path:weight. Sampler traducido via samplerMap canonico."
tags: [ml, stable-diffusion, cli, args, generation, pure]
uses_functions: []
uses_types: [generation_config_go_ml]
returns: []
returns_optional: false
error_type: ""
imports: ["fmt", "strconv"]
params:
- name: cfg
desc: "Parametros completos de generacion de imagen. Sampler debe ser uno de los valores de SamplerName. Model.Path se emite como --model si no esta vacio."
output: "Slice de strings listos para pasar a exec.Command o similar. Incluye --prompt, --seed, --steps, --cfg-scale, --width, --height, --sampling-method, opcionales --negative-prompt / --model / --clip-skip, y pares --lora path:weight por cada LoraRef."
tested: true
tests:
- "config basico sin loras ni clip_skip"
- "loras se emiten como pares path:weight"
- "sampler dpm++2m se traduce a dpmpp2m"
- "negative_prompt vacio no genera flag"
test_file_path: "functions/ml/genconfig_test.go"
file_path: "functions/ml/genconfig_to_sdcli_args.go"
---
## Ejemplo
```go
clip := 2
cfg := ml.GenerationConfig{
Prompt: "a cat",
Seed: 42,
Steps: 20,
CfgScale: 7.5,
Sampler: "dpm++2m",
Width: 512,
Height: 512,
Model: ml.ModelRef{Name: "v1-5", ModelType: "sd15", Quantization: "fp16", Path: "/models/v1-5.safetensors"},
Loras: []ml.LoraRef{{Path: "/loras/detail.safetensors", Weight: 0.8}},
ClipSkip: &clip,
}
args := ml.GenconfigToSdcliArgs(cfg)
// args == ["--prompt","a cat","--seed","42","--steps","20",
// "--cfg-scale","7.5","--width","512","--height","512",
// "--sampling-method","dpmpp2m","--model","/models/v1-5.safetensors",
// "--clip-skip","2","--lora","/loras/detail.safetensors:0.8"]
```
## Notas
- `samplerMap` traduce nombres canonicos del dominio ml a los identificadores que acepta stable-diffusion.cpp. Si el sampler no esta en el mapa se usa el valor literal.
- El flag de modelo (`--model`) solo se emite si `cfg.Model.Path != ""`.
- `%g` en `fmt.Sprintf` para el peso de la lora elimina ceros insignificantes: `0.800000``0.8`.
- Funcion pura: misma entrada, misma salida. Sin I/O ni estado global.
+18
View File
@@ -0,0 +1,18 @@
package ml
// GenerationConfig parametriza una solicitud de generacion de imagen.
// Espejo JSON-compatible de GenerationConfig_py_ml: los tags json coinciden
// con los campos snake_case del dataclass Python para roundtrip sin perdida.
type GenerationConfig struct {
Prompt string `json:"prompt"`
NegativePrompt string `json:"negative_prompt,omitempty"`
Seed int64 `json:"seed"`
Steps int `json:"steps"`
CfgScale float64 `json:"cfg_scale"`
Sampler string `json:"sampler"`
Width int `json:"width"`
Height int `json:"height"`
Model ModelRef `json:"model"`
Loras []LoraRef `json:"loras,omitempty"`
ClipSkip *int `json:"clip_skip,omitempty"`
}
+12
View File
@@ -0,0 +1,12 @@
package ml
// ImageGenResult contiene la imagen generada y su metadata de ejecucion.
// ImageBytes transporta los bytes raw del PNG y se excluye del JSON
// (campo json:"-") porque viaja por canal binario separado.
type ImageGenResult struct {
ImageBytes []byte `json:"-"`
Format string `json:"format"`
Meta map[string]any `json:"meta"`
DurationMs int64 `json:"duration_ms"`
VramPeakMb *int `json:"vram_peak_mb,omitempty"`
}
+9
View File
@@ -0,0 +1,9 @@
package ml
import "context"
// ImageGenerator define el contrato para cualquier backend de generacion de imagenes.
// Las implementaciones pueden ser locales (ComfyUI, diffusers) o remotas (API).
type ImageGenerator interface {
Generate(ctx context.Context, cfg GenerationConfig) (ImageGenResult, error)
}
+8
View File
@@ -0,0 +1,8 @@
package ml
// LoraRef referencia un adaptador LoRA con su peso de fusión y escala opcional.
type LoraRef struct {
Path string `json:"path"`
Weight float64 `json:"weight"`
Scale *float64 `json:"scale,omitempty"`
}
+10
View File
@@ -0,0 +1,10 @@
package ml
// ModelRef identifica un modelo de generacion de imagenes por nombre, tipo,
// cuantizacion y path opcional en disco.
type ModelRef struct {
Name string `json:"name"`
ModelType string `json:"model_type"` // sd15|sdxl|flux_dev|...
Quantization string `json:"quantization"` // fp16|q8_0|...
Path string `json:"path,omitempty"`
}
+78
View File
@@ -0,0 +1,78 @@
package ml
import (
"regexp"
"strconv"
)
// SdcliProgress contiene el estado de progreso parseado de una linea de stderr de sd-cli.
type SdcliProgress struct {
Step int `json:"step"`
TotalSteps int `json:"total_steps"`
ItPerSec float64 `json:"it_per_sec"`
Percent float64 `json:"percent"`
}
// reProgress1 parsea el formato compacto: " 3/30 | 0.84it/s | 10%"
var reProgress1 = regexp.MustCompile(`\s*(\d+)\s*/\s*(\d+)\s*\|[^|]*?([\d.]+)\s*it/s[^|]*?\|\s*([\d.]+)\s*%`)
// reProgress2 parsea el formato verbose: "sampling: step 3 of 30 (0.84 it/s)"
var reProgress2 = regexp.MustCompile(`step\s+(\d+)\s+of\s+(\d+)\s*\(\s*([\d.]+)\s*it/s\)`)
// reProgress3 parsea el formato minimal: "step 3/30" o "progress: 3/30"
var reProgress3 = regexp.MustCompile(`(?:progress[:\s]+)?(\d+)\s*/\s*(\d+)`)
// SdcliParseProgress parsea una linea de stderr de stable-diffusion.cpp / sd-cli
// y extrae el estado de progreso. Retorna (SdcliProgress, true) si la linea
// contiene informacion de progreso reconocible; (zero, false) en caso contrario.
// Funcion pura: sin I/O, sin estado mutable, determinista.
func SdcliParseProgress(line string) (SdcliProgress, bool) {
// Formato 1: " 3/30 | 0.84it/s | 10%"
if m := reProgress1.FindStringSubmatch(line); m != nil {
step, err1 := strconv.Atoi(m[1])
total, err2 := strconv.Atoi(m[2])
itPerSec, err3 := strconv.ParseFloat(m[3], 64)
pct, err4 := strconv.ParseFloat(m[4], 64)
if err1 == nil && err2 == nil && err3 == nil && err4 == nil {
return SdcliProgress{
Step: step,
TotalSteps: total,
ItPerSec: itPerSec,
Percent: pct,
}, true
}
}
// Formato 2: "sampling: step 3 of 30 (0.84 it/s)"
if m := reProgress2.FindStringSubmatch(line); m != nil {
step, err1 := strconv.Atoi(m[1])
total, err2 := strconv.Atoi(m[2])
itPerSec, err3 := strconv.ParseFloat(m[3], 64)
if err1 == nil && err2 == nil && err3 == nil && total > 0 {
pct := 100.0 * float64(step) / float64(total)
return SdcliProgress{
Step: step,
TotalSteps: total,
ItPerSec: itPerSec,
Percent: pct,
}, true
}
}
// Formato 3: "step 3/30" o "progress: 3/30" sin velocidad
if m := reProgress3.FindStringSubmatch(line); m != nil {
step, err1 := strconv.Atoi(m[1])
total, err2 := strconv.Atoi(m[2])
if err1 == nil && err2 == nil && total > 0 {
pct := 100.0 * float64(step) / float64(total)
return SdcliProgress{
Step: step,
TotalSteps: total,
ItPerSec: 0,
Percent: pct,
}, true
}
}
return SdcliProgress{}, false
}
+50
View File
@@ -0,0 +1,50 @@
---
name: sdcli_parse_progress
kind: function
lang: go
domain: ml
version: "1.0.0"
purity: pure
signature: "func SdcliParseProgress(line string) (SdcliProgress, bool)"
description: "Parsea una linea de stderr de stable-diffusion.cpp / sd-cli y extrae el estado de progreso. Soporta el formato compacto '3/30 | 0.84it/s | 10%', el formato verbose 'sampling: step 3 of 30 (0.84 it/s)', y el formato minimal 'progress: 3/30'. Retorna (zero, false) si la linea no contiene informacion de progreso reconocible."
tags: [ml, stable-diffusion, sdcli, progress, parser, stderr, pure]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: ["regexp", "strconv"]
params:
- name: line
desc: "Una linea de stderr emitida por sd-cli / stable-diffusion.cpp durante la fase de sampling. Puede contener espacios al inicio o final."
output: "Par (SdcliProgress, bool). bool=true si se reconocio un patron de progreso; SdcliProgress contiene Step (paso actual), TotalSteps (pasos totales), ItPerSec (iteraciones por segundo, 0 si no disponible) y Percent (porcentaje 0-100 calculado o leido de la linea). bool=false y struct zero si la linea no contiene progreso."
tested: true
tests:
- "formato estandar compacto step/total/itpersec/percent"
- "linea sin patron retorna false"
- "formato sampling verbose con velocidad"
file_path: "functions/ml/sdcli_parse_progress.go"
test_file_path: "functions/ml/sdcli_parse_progress_test.go"
---
## Ejemplo
```go
p, ok := ml.SdcliParseProgress(" 3/30 | 0.84it/s | 10%")
// ok = true
// p = SdcliProgress{Step:3, TotalSteps:30, ItPerSec:0.84, Percent:10.0}
p2, ok2 := ml.SdcliParseProgress("sampling: step 15 of 30 (1.2 it/s)")
// ok2 = true
// p2 = SdcliProgress{Step:15, TotalSteps:30, ItPerSec:1.2, Percent:50.0}
_, ok3 := ml.SdcliParseProgress("loading model...")
// ok3 = false
```
## Notas
- Regexps precompiladas como vars de paquete (se compilan una sola vez al init del paquete).
- Tolerante a variaciones de espaciado gracias a `\s*` en los patrones.
- El campo `Percent` en el formato verbose se calcula como `100 * step / total` (no se lee de la linea porque ese formato no lo emite).
- Funcion pura: sin I/O, sin estado mutable, determinista.
+103
View File
@@ -0,0 +1,103 @@
package ml
import (
"math"
"testing"
)
func TestSdcliParseProgress_StandardFormat(t *testing.T) {
line := " 3/30 | 0.84it/s | 10%"
got, ok := SdcliParseProgress(line)
if !ok {
t.Fatalf("expected match, got false")
}
if got.Step != 3 {
t.Errorf("Step: got %d, want 3", got.Step)
}
if got.TotalSteps != 30 {
t.Errorf("TotalSteps: got %d, want 30", got.TotalSteps)
}
if math.Abs(got.ItPerSec-0.84) > 1e-9 {
t.Errorf("ItPerSec: got %v, want 0.84", got.ItPerSec)
}
if math.Abs(got.Percent-10.0) > 1e-9 {
t.Errorf("Percent: got %v, want 10.0", got.Percent)
}
}
func TestSdcliParseProgress_NoMatch(t *testing.T) {
cases := []string{
"loading model...",
"",
"error: out of memory",
"clip model loaded",
"generating image...",
}
for _, line := range cases {
_, ok := SdcliParseProgress(line)
if ok {
t.Errorf("expected no match for %q, but got match", line)
}
}
}
func TestSdcliParseProgress_AltFormat(t *testing.T) {
t.Run("formato sampling verbose", func(t *testing.T) {
line := "sampling: step 3 of 30 (0.84 it/s)"
got, ok := SdcliParseProgress(line)
if !ok {
t.Fatalf("expected match, got false")
}
if got.Step != 3 {
t.Errorf("Step: got %d, want 3", got.Step)
}
if got.TotalSteps != 30 {
t.Errorf("TotalSteps: got %d, want 30", got.TotalSteps)
}
if math.Abs(got.ItPerSec-0.84) > 1e-9 {
t.Errorf("ItPerSec: got %v, want 0.84", got.ItPerSec)
}
expectedPct := 100.0 * 3.0 / 30.0
if math.Abs(got.Percent-expectedPct) > 1e-6 {
t.Errorf("Percent: got %v, want %v", got.Percent, expectedPct)
}
})
t.Run("formato step/total sin velocidad", func(t *testing.T) {
line := "progress: 15/20"
got, ok := SdcliParseProgress(line)
if !ok {
t.Fatalf("expected match, got false")
}
if got.Step != 15 {
t.Errorf("Step: got %d, want 15", got.Step)
}
if got.TotalSteps != 20 {
t.Errorf("TotalSteps: got %d, want 20", got.TotalSteps)
}
if got.ItPerSec != 0 {
t.Errorf("ItPerSec: got %v, want 0", got.ItPerSec)
}
expectedPct := 75.0
if math.Abs(got.Percent-expectedPct) > 1e-6 {
t.Errorf("Percent: got %v, want %v", got.Percent, expectedPct)
}
})
t.Run("formato con espacios variables y mayor velocidad", func(t *testing.T) {
line := " 20/30 | 12.50it/s | 66%"
got, ok := SdcliParseProgress(line)
if !ok {
t.Fatalf("expected match, got false")
}
if got.Step != 20 {
t.Errorf("Step: got %d, want 20", got.Step)
}
if got.TotalSteps != 30 {
t.Errorf("TotalSteps: got %d, want 30", got.TotalSteps)
}
if math.Abs(got.ItPerSec-12.5) > 1e-9 {
t.Errorf("ItPerSec: got %v, want 12.5", got.ItPerSec)
}
})
}