Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions internal/cli/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,17 @@ other collaborators train against it without ever seeing the raw files.`))
// synthesized spec carries the right fields before validation.
switch {
case push.IsTabular(a.Spec.Category):
// P3 (cli#71): a BOM'd tabular CSV is doomed in-cluster AND would
// corrupt InferSchema's own header read below — reject before
// either. The rest of the content preflight runs after the spec
// schema validation (mirroring the in-cluster order).
if perr := push.CheckTabularBOM(layout.LabelsCSV); perr != nil {
return &exitError{code: 3, err: perr}
}
if perr := push.CheckHasDataRows(layout.LabelsCSV); perr != nil {
return &exitError{code: 3, err: perr}
}

// Column schema: an explicit --schema wins; otherwise infer
// INT/FLOAT/VARCHAR types from the CSV so the customer doesn't
// hand-write one for the common case.
Expand Down Expand Up @@ -615,6 +626,15 @@ other collaborators train against it without ever seeing the raw files.`))
return &exitError{code: 2, err: errors.New("synthesized spec failed schema validation; check the flag values above")}
}

// P3 content preflight (backend#828, cli#69/#71/#72/#73): preview the
// ingestor's own validators locally — AFTER the spec schema validation,
// mirroring the in-cluster order (jsonschema first, then validators),
// and BEFORE any cluster work. Each check names the rule it previews;
// parity is pinned by internal/push/parity_golden_test.go.
if perr := runLocalPreflight(a, layout, errOut); perr != nil {
return perr
}

printLocalSummary(a.Printer, layout, spec)

// 5. Cluster discovery — same kubeconfig path as `cluster info`.
Expand Down Expand Up @@ -1042,3 +1062,22 @@ func destTableExists(ctx context.Context, cs kubernetes.Interface, resolved *clu
}
return "", ""
}

// runLocalPreflight maps push.PreflightDataset — THE shared preview
// dispatch, also exercised verbatim by the parity harness — onto the CLI's
// conventions: notes print dim to errOut, a BadFlag problem exits 2 (fix a
// flag), anything else exits 3 (fix the data).
func runLocalPreflight(a runDataIngestArgs, layout *push.LocalLayout, errOut io.Writer) error {
notes, problem := push.PreflightDataset(a.Spec, layout)
for _, n := range notes {
_, _ = fmt.Fprintln(errOut, n)
}
if problem == nil {
return nil
}
code := 3
if problem.BadFlag {
code = 2
}
return &exitError{code: code, err: problem.Err}
}
19 changes: 15 additions & 4 deletions internal/cli/data_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package cli
import (
"github.com/tracebloc/cli/internal/push"
"github.com/tracebloc/cli/internal/ui"
"image"
"image/png"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"

Expand All @@ -24,16 +26,25 @@ func imgcLayout(t *testing.T) string {
t.Helper()
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "labels.csv"),
[]byte("image_id,label\n001.jpg,cat\n"), 0o644); err != nil {
[]byte("image_id,label\n001.jpg,cat\n002.jpg,dog\n"), 0o644); err != nil {
t.Fatalf("write labels.csv: %v", err)
}
imagesDir := filepath.Join(root, "images")
if err := os.MkdirAll(imagesDir, 0o755); err != nil {
t.Fatalf("mkdir images: %v", err)
}
if err := os.WriteFile(filepath.Join(imagesDir, "001.jpg"),
make([]byte, 100), 0o644); err != nil {
t.Fatalf("write image: %v", err)
// Real decodable images with two classes: the P3 preflight decodes
// EVERY image and requires >=2 label classes, so opaque stubs would
// short-circuit any test that needs to get past preflight (a
// kubeconfig test failed vacuously on exactly that).
var buf bytes.Buffer
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 4, 4))); err != nil {
t.Fatal(err)
}
for _, name := range []string{"001.jpg", "002.jpg"} {
if err := os.WriteFile(filepath.Join(imagesDir, name), buf.Bytes(), 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
return root
}
Expand Down
126 changes: 126 additions & 0 deletions internal/push/parity_golden_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package push

import (
"encoding/json"
"os"
"path/filepath"
"sort"
"testing"
)

// The validator-parity harness (backend#828 P3). Two assertions per case:
//
// 1. the Go preflight's verdict matches the manifest's cli_verdict —
// pins the CLI side;
// 2. the COMMITTED goldens (generated from the real data-ingestors
// validators by scripts/gen-validator-goldens.py) match the
// manifest's ingestor_verdict — so when the ingestor's rules change,
// regenerating the goldens fails this test until the manifest (and,
// where needed, the Go preview) is consciously updated.
//
// Deliberate divergences (the CLI previewing read-/transfer-time failures
// the ingestor's preflight can't see) are explicit in the manifest, never
// silent.

type parityCase struct {
Name string `json:"name"`
Category string `json:"category"`
CSV string `json:"csv"`
LabelColumn string `json:"label_column"`
Extension string `json:"extension"`
TargetSize []int `json:"target_size"`
CLIVerdict string `json:"cli_verdict"`
IngestorVerdict string `json:"ingestor_verdict"`
Note string `json:"note"`
}

func TestValidatorParity(t *testing.T) {
var manifest struct {
Cases []parityCase `json:"cases"`
}
mustLoad(t, filepath.Join("testdata", "parity", "cases.json"), &manifest)

var goldens struct {
Verdicts map[string]struct {
Verdict string `json:"verdict"`
Errors []string `json:"errors"`
} `json:"verdicts"`
}
mustLoad(t, filepath.Join("testdata", "parity", "goldens.json"), &goldens)

for _, c := range manifest.Cases {
t.Run(c.Name, func(t *testing.T) {
golden, ok := goldens.Verdicts[c.Name]
if !ok {
t.Fatalf("no golden for %s — run scripts/gen-validator-goldens.py", c.Name)
}
if golden.Verdict != c.IngestorVerdict {
t.Errorf("the REAL ingestor validators say %q but the manifest expects %q — "+
"the ingestor's rules changed; update cases.json (and the Go preview if needed). "+
"Golden errors: %v", golden.Verdict, c.IngestorVerdict, golden.Errors)
}
got := runGoPreflight(t, c)
if got != c.CLIVerdict {
t.Errorf("Go preflight = %q, manifest expects %q (note: %s)", got, c.CLIVerdict, c.Note)
}
})
}
}

// runGoPreflight runs THE production dispatch (push.PreflightDataset) over
// the case — the same code path runDataIngest executes, so a check deleted
// or rewired in production fails parity here.
func runGoPreflight(t *testing.T, c parityCase) string {
t.Helper()
dir := filepath.Join("testdata", "parity", "cases", c.Name)
layout := &LocalLayout{
Root: dir,
LabelsCSV: filepath.Join(dir, c.CSV),
Images: listImages(t, dir),
}
spec := SpecArgs{
Category: c.Category,
LabelColumn: c.LabelColumn,
Extension: c.Extension,
TargetSize: c.TargetSize,
}
if IsTabular(c.Category) {
// Mirror runDataIngest: the schema is inferred from the CSV before
// the preflight runs (the schema-columns preview needs it).
if sch, _, _, err := InferSchema(layout.LabelsCSV); err == nil {
spec.Schema = sch
}
}
_, problem := PreflightDataset(spec, layout)
if problem != nil {
return "reject"
}
return "accept"
}

func listImages(t *testing.T, dir string) []string {
t.Helper()
entries, err := os.ReadDir(filepath.Join(dir, "images"))
if err != nil {
return nil // tabular/text cases have no images/
}
var out []string
for _, e := range entries {
if !e.IsDir() {
out = append(out, filepath.Join(dir, "images", e.Name()))
}
}
sort.Strings(out)
return out
}

func mustLoad(t *testing.T, path string, into any) {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(b, into); err != nil {
t.Fatalf("%s: %v", path, err)
}
}
Loading
Loading