From 0a573fb5d4de6efd0f831ae85189e9e3ca621c1a Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:12:47 +0200 Subject: [PATCH 01/22] Merge pull request #196 from tracebloc/feat/179-plain-language-ceremony feat(data ingest): plain-language copy + hide k8s ceremony + progress on every wait (#179) --- internal/cli/coverage_test.go | 39 ++++++++++- internal/cli/data.go | 100 ++++++++++++++++++----------- internal/cli/ingestion_run_test.go | 56 ++++++++++++++++ internal/push/stage.go | 10 +-- internal/push/stage_test.go | 2 +- internal/submit/submit.go | 18 ++++-- 6 files changed, 177 insertions(+), 48 deletions(-) diff --git a/internal/cli/coverage_test.go b/internal/cli/coverage_test.go index 850dd448..586ee645 100644 --- a/internal/cli/coverage_test.go +++ b/internal/cli/coverage_test.go @@ -48,8 +48,10 @@ func TestPrintPushPreflight_RendersKeyFacts(t *testing.T) { "label": "label", } + // Verbose so the cluster block renders too — it's --verbose-only now (see + // TestPrintClusterSummary_VerboseOnly). The local summary shows regardless. var buf bytes.Buffer - p := ui.New(&buf, ui.WithColor(false)) + p := ui.New(&buf, ui.WithColor(false), ui.WithVerbose(true)) printLocalSummary(p, layout, spec) printClusterSummary(p, release, pvc) out := buf.String() @@ -64,6 +66,41 @@ func TestPrintPushPreflight_RendersKeyFacts(t *testing.T) { } } +// TestPrintClusterSummary_VerboseOnly pins that the Kubernetes cluster detail +// (release / jobs-manager / shared PVC) is hidden on the default happy path and +// surfaces only under --verbose — the RFC-0002 §6 ceremony-hiding contract. +func TestPrintClusterSummary_VerboseOnly(t *testing.T) { + release := &cluster.ParentRelease{ + ReleaseName: "ingdemo", + ChartVersion: "1.4.2", + JobsManagerService: "http://jobs-manager.ingdemo.svc.cluster.local:8080", + } + pvc := &cluster.SharedPVC{ + ClaimName: "client-pvc", + MountPath: "/data/shared", + Phase: corev1.ClaimBound, + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}, + } + + // Default (non-verbose): none of the cluster plumbing leaks. + var quiet bytes.Buffer + printClusterSummary(ui.New(&quiet, ui.WithColor(false)), release, pvc) + for _, hidden := range []string{"ingdemo", "1.4.2", "client-pvc", "jobs-manager", "Target cluster"} { + if strings.Contains(quiet.String(), hidden) { + t.Errorf("non-verbose output leaked cluster detail %q:\n%s", hidden, quiet.String()) + } + } + + // --verbose: the same facts are shown. + var loud bytes.Buffer + printClusterSummary(ui.New(&loud, ui.WithColor(false), ui.WithVerbose(true)), release, pvc) + for _, want := range []string{"ingdemo", "1.4.2", "client-pvc"} { + if !strings.Contains(loud.String(), want) { + t.Errorf("verbose output missing cluster detail %q:\n%s", want, loud.String()) + } + } +} + // TestWritePushJSON checks the --output-json result serializes to // valid JSON with the expected fields. func TestWritePushJSON(t *testing.T) { diff --git a/internal/cli/data.go b/internal/cli/data.go index 6794b4b8..f1912d73 100644 --- a/internal/cli/data.go +++ b/internal/cli/data.go @@ -388,11 +388,10 @@ func runDataIngest(ctx context.Context, out, errOut io.Writer, a runDataIngestAr a.Printer.Banner("tracebloc", "data ingest") a.Printer.Para(strings.TrimSpace(` -This uploads a dataset from your machine into your tracebloc workspace so models -can be trained on it. Your files are sent to the Kubernetes cluster your -workspace was installed on — tracebloc checks them and loads them into a table -your training runs read from. Your data stays on that cluster the whole time; -other collaborators train against it without ever seeing the raw files.`)) +This ingests a dataset so models can train on it. Your files never leave your +own infrastructure — tracebloc copies them into your workspace's storage, +checks them, and loads them into a table your training runs read from. Other +collaborators can train against that table without ever seeing the raw files.`)) a.Printer.Hintf("Learn more: https://docs.tracebloc.io") // 0. Guided mode: prompt for any missing core inputs before @@ -471,8 +470,10 @@ other collaborators train against it without ever seeing the raw files.`)) // resolution below needs (the image list for target-size, the // CSV for schema inference). // err is the function's named return (see the --output-json defer - // at the top), so it's not redeclared here. + // at the top), so it's not redeclared here. The walk can take a moment + // on a large tree, so it gets a spinner — no blocking wait stays silent. var layout *push.LocalLayout + walkSpin := a.Printer.Spinner("Reading your files", "") switch { case push.IsTabular(a.Spec.Category): layout, err = push.DiscoverTabular(a.LocalPath) @@ -484,12 +485,13 @@ other collaborators train against it without ever seeing the raw files.`)) // image_classification + keypoint_detection: labels.csv + images/. layout, err = push.Discover(a.LocalPath) } + walkSpin.Stop() if err != nil { return &exitError{code: 3, err: err} } - a.Printer.Step(1, 4, "Check your dataset") - a.Printer.Hintf("Reading your files locally first — nothing has touched the cluster yet — so a layout or settings problem shows up right away.") + a.Printer.Step(1, 3, "Check your data") + a.Printer.Hintf("Reading your files locally first — nothing has touched your workspace yet — so a layout or settings problem shows up right away.") // 3a. Per-category spec resolution from the local data, so the // synthesized spec carries the right fields before validation. @@ -634,8 +636,18 @@ other collaborators train against it without ever seeing the raw files.`)) // Errors mirror that command's exit-code contract (3 for // kubeconfig, 4 for missing release) so behaviour is // consistent across pre-flight commands. - a.Printer.Step(2, 4, "Connect to your workspace") - a.Printer.Hintf("Finding your tracebloc workspace and the shared storage your dataset will live on.") + // Connecting to the workspace + discovering its shared storage is + // Kubernetes plumbing (release / PVC / jobs-manager) the happy path keeps + // quiet — it's no longer a numbered step (RFC-0002 §6), and --verbose adds + // the release/PVC detail below. But the discovery itself is several blocking + // apiserver round-trips (kubeconfig load, release + PVC discovery, then the + // destination-exists check), so it still needs a visible status line — no + // silent wait on the happy path (RFC-0002 "progress on every wait"). + // A plain line, not a spinner: discoverRelease can print its own + // namespace-fallback note mid-call, and a spinner's \r redraw would clobber + // it. ALL the logic below (discovery + the exit-6 destination guard) is + // unchanged; only the presentation moved. + a.Printer.Infof("Connecting to your workspace…") // 6. PVC discovery (needPVC) confirms the chart's shared-data PVC is // Bound before we waste time provisioning a Pod that can't mount it. opts := cluster.KubeconfigOptions{Path: a.Kubeconfig, Context: a.Context, Namespace: a.Namespace} @@ -649,8 +661,9 @@ other collaborators train against it without ever seeing the raw files.`)) // DiscoverParentRelease (#7) and flows into the stage/teardown pods + the // jobs-manager token mint below — no --ingestor-sa override. - // 7. Show what we found on the cluster — the customer's last look - // before any bytes move. + // 7. Under --verbose, show what we found on the cluster; the happy path + // keeps this Kubernetes detail hidden (printClusterSummary is a no-op + // without --verbose). printClusterSummary(a.Printer, release, pvc) // 8a. Destination guard (cli#70, P4-lite): re-ingesting an existing @@ -679,8 +692,8 @@ other collaborators train against it without ever seeing the raw files.`)) // live-only steps (stage + ingest) the customer just skipped. if a.DryRun { a.Printer.Newline() - a.Printer.Successf("Dry-run complete — your dataset and cluster check out; nothing was created.") - a.Printer.Hintf("A real run continues with step 3 (stage your files) and step 4 (run the ingestion).") + a.Printer.Successf("Dry-run complete — your data and workspace check out; nothing was created.") + a.Printer.Hintf("A real run continues with step 2 (copy into your workspace) and step 3 (validate and load).") if a.OutputJSON { writePushJSON(a.JSONOut, "dry-run", spec, nil, "", "") jsonEmitted = true @@ -695,16 +708,18 @@ other collaborators train against it without ever seeing the raw files.`)) // are case-sensitive on Linux MySQL and PVC paths always are, so // acting on a differently-cased --table would silently no-op the // DROP/rm and then "succeed". - a.Printer.Infof("Removing the existing %q first…", existingTable) plan := push.PlanTeardown(existingTable) - if _, terr := push.Teardown(ctx, cs, &push.SPDYExecutor{Config: resolved.RestConfig, Client: cs}, resolved.Namespace, plan, push.PodSpecOptions{ + rmSpin := a.Printer.Spinner(fmt.Sprintf("Removing the existing %q first", existingTable), "") + _, terr := push.Teardown(ctx, cs, &push.SPDYExecutor{Config: resolved.RestConfig, Client: cs}, resolved.Namespace, plan, push.PodSpecOptions{ Namespace: resolved.Namespace, PVCClaimName: pvc.ClaimName, PVCMountPath: pvc.MountPath, Table: existingTable, ServiceAccountName: release.IngestorSAName, Image: a.StagePodImage, - }); terr != nil { + }) + rmSpin.Stop() + if terr != nil { // The teardown drops the table before removing files, so a // partial failure can leave files the DB-backed guard can no // longer see — a plain re-run would upload everything and then @@ -726,10 +741,10 @@ other collaborators train against it without ever seeing the raw files.`)) // Exit code 7 ("staging failed") is distinct from the // pre-flight codes so customers can branch on whether the // failure was their environment vs the actual data transfer. - a.Printer.Step(3, 4, "Stage your files") - a.Printer.Hintf("Your files upload securely into your workspace's storage — set up and cleaned up for you.") + a.Printer.Step(2, 3, "Copy into your workspace") + a.Printer.Hintf("Your files are copied securely into your workspace's storage — set up and cleaned up for you.") progress := push.NewProgress(out, layout.TotalBytes, - fmt.Sprintf("Staging %s", a.Spec.Table)) + fmt.Sprintf("Copying %s", a.Spec.Table)) // Defer Finish so a failure path that returns BEFORE // StreamLayout (e.g. CreateStagePod fails on PSA rejection, // WaitForStagePodReady times out) still clears the TTY @@ -794,7 +809,7 @@ func runIngestionRun(ctx context.Context, out io.Writer, a runDataIngestArgs, ta // min) because the full Phase 4 lifecycle — submit + watch // + log stream — can run that long for large ingestions. // The chart's helm flow uses the same token-mint code path. - a.Printer.Step(4, 4, "Run the ingestion") + a.Printer.Step(3, 3, "Validate and load") if a.Detach { a.Printer.Hintf("Submitting the run — with --detach it keeps running on your workspace after this command returns; the reconnect command is shown below.") } else { @@ -814,9 +829,15 @@ func runIngestionRun(ctx context.Context, out io.Writer, a runDataIngestArgs, ta // through the kubeconfig-authenticated apiserver, same as // `kubectl port-forward`. Bugbot PR #10 r3 caught the // original broken-by-design direct-URL POST. - a.Printer.Infof("Connecting to your workspace to submit the run…") + // Opening the port-forward is a blocking wait (tunnel setup through the + // apiserver), so it runs under a spinner — no wait on the happy path stays + // silent (RFC-0002 "progress on every wait"). The submit POST itself is a + // separate ~30s synchronous wait; its spinner lives in submit.Run, next to + // the POST it covers. + connectSpin := a.Printer.Spinner("Connecting to your workspace to submit the run", "") pf, err := portForwardJobsManagerFn(ctx, cs, resolved.RestConfig, resolved.Namespace, release.JobsManagerServiceName, release.JobsManagerPort) + connectSpin.Stop() if err != nil { return false, &exitError{code: 8, err: fmt.Errorf("setting up jobs-manager port-forward: %w", err)} } @@ -881,8 +902,8 @@ func runIngestionRun(ctx context.Context, out io.Writer, a runDataIngestArgs, ta // (push.StagingCleanupTimeout): a failed or slow reclaim must not // fail — or noticeably delay — a successful ingest. if shouldReclaimStaging(status) { - a.Printer.Infof("Reclaiming the temporary staging copy on the cluster…") - if cerr := cleanStagingFn(ctx, cs, + reclaimSpin := a.Printer.Spinner("Reclaiming the temporary copy", "") + cerr := cleanStagingFn(ctx, cs, &push.SPDYExecutor{Config: resolved.RestConfig, Client: cs}, resolved.Namespace, a.Spec.Table, push.PodSpecOptions{ Namespace: resolved.Namespace, @@ -891,8 +912,10 @@ func runIngestionRun(ctx context.Context, out io.Writer, a runDataIngestArgs, ta Table: a.Spec.Table, ServiceAccountName: release.IngestorSAName, Image: a.StagePodImage, - }); cerr != nil { - a.Printer.Warnf("Couldn't reclaim the temporary staging copy (%v). It's harmless — the next re-ingest of %q or a `tracebloc data delete %s` will clear it.", + }) + reclaimSpin.Stop() + if cerr != nil { + a.Printer.Warnf("Couldn't reclaim the temporary copy (%v). It's harmless — the next re-ingest of %q or a `tracebloc data delete %s` will clear it.", cerr, a.Spec.Table, a.Spec.Table) } } @@ -963,9 +986,8 @@ func classifyPushOutcome(res *submit.Result, err error) (string, *exitError) { } // printLocalSummary shows what the CLI found on disk plus the ingest -// settings it assembled — the detail under step 1 ("Check your -// dataset"). Split from the cluster summary so each sits under its own -// numbered step. Mirrors `cluster info`'s section/Field layout. +// settings it assembled — the detail under step 1 ("Check your data"). +// Mirrors `cluster info`'s section/Field layout. func printLocalSummary(p *ui.Printer, layout *push.LocalLayout, spec map[string]any) { cat, _ := spec["category"].(string) @@ -1017,17 +1039,23 @@ func printLocalSummary(p *ui.Printer, layout *push.LocalLayout, spec map[string] p.Field("destination", push.FinalDestPrefix(spec["table"].(string))) } -// printClusterSummary shows the discovered workspace cluster target — -// the detail under step 2 ("Connect to your workspace"). +// printClusterSummary shows the discovered workspace target. It's Kubernetes +// plumbing (release / jobs-manager / shared PVC) the happy path hides, so the +// whole block — header, fields, and the RWO-PVC note — prints only under +// --verbose (RFC-0002 §6). Discovery + guards are unchanged; this is +// presentation only. func printClusterSummary(p *ui.Printer, release *cluster.ParentRelease, pvc *cluster.SharedPVC) { + if !p.Verbose() { + return + } p.Section("Target cluster") - p.Field("release", fmt.Sprintf("%s (chart %s)", release.ReleaseName, release.ChartVersion)) - p.Field("jobs-manager", release.JobsManagerService) - p.Field("shared PVC", fmt.Sprintf("%s (%s)", pvc.ClaimName, pvc.Phase)) + p.Detailf("release: %s (chart %s)", release.ReleaseName, release.ChartVersion) + p.Detailf("jobs-manager: %s", release.JobsManagerService) + p.Detailf("shared PVC: %s (%s)", pvc.ClaimName, pvc.Phase) if !pvc.IsReadWriteMany() { - // Warn but don't block — RWO clusters still work; the scheduler + // Note but don't block — RWO clusters still work; the scheduler // co-locates the stage Pod with the existing mounter. - p.Warnf("PVC is %v, not ReadWriteMany — the stage Pod will co-locate with the existing mounter", pvc.AccessModes) + p.Detailf("PVC is %v, not ReadWriteMany — the stage Pod will co-locate with the existing mounter", pvc.AccessModes) } } diff --git a/internal/cli/ingestion_run_test.go b/internal/cli/ingestion_run_test.go index c7eb7af9..cf4639a7 100644 --- a/internal/cli/ingestion_run_test.go +++ b/internal/cli/ingestion_run_test.go @@ -6,6 +6,8 @@ import ( "encoding/json" "errors" "io" + "runtime" + "strings" "testing" "github.com/tracebloc/cli/internal/cluster" @@ -160,6 +162,60 @@ func TestRunIngestionRun_Matrix(t *testing.T) { } } +// TestRunIngestionRun_SubmitConnectUsesSpinner pins the RFC-0002 "progress on +// every wait" rule for the submit-connect step: opening the port-forward blocks +// on a POST that can take ~30s, so it must run under a live spinner, not a +// silent or plain line. Colour is forced on so the animated path (which emits +// the \r redraw) renders against a buffer; the returned handle is always +// safe to Stop (the nil-safe static path is exercised by the matrix test, which +// runs with colour off). +func TestRunIngestionRun_SubmitConnectUsesSpinner(t *testing.T) { + origMint, origPF, origRun, origClean := mintIngestorTokenFn, portForwardJobsManagerFn, submitRunFn, cleanStagingFn + defer func() { + mintIngestorTokenFn, portForwardJobsManagerFn, submitRunFn, cleanStagingFn = origMint, origPF, origRun, origClean + }() + mintIngestorTokenFn = func(context.Context, kubernetes.Interface, string, string, int64, []string) (*cluster.IngestorToken, error) { + return &cluster.IngestorToken{Token: "tok"}, nil + } + portForwardJobsManagerFn = func(context.Context, kubernetes.Interface, *rest.Config, string, string, int) (*submit.ForwardedConnection, error) { + return &submit.ForwardedConnection{LocalPort: 12345}, nil + } + submitRunFn = func(context.Context, submit.Options) (*submit.Result, error) { + return succeededResult(), nil + } + cleanStagingFn = func(context.Context, kubernetes.Interface, push.Executor, string, string, push.PodSpecOptions) error { + return nil + } + + target := &clusterTarget{ + Resolved: &cluster.ResolvedConfig{Namespace: "tracebloc"}, + Release: &cluster.ParentRelease{IngestorSAName: "ingestor", JobsManagerServiceName: "jm", JobsManagerPort: 8080}, + PVC: &cluster.SharedPVC{ClaimName: "pvc", MountPath: "/data/shared"}, + } + spec := map[string]any{"table": "t", "category": "image_classification", "intent": "train", "label": "label"} + + var buf bytes.Buffer + a := runDataIngestArgs{ + Spec: push.SpecArgs{Table: "t"}, + Printer: ui.New(&buf, ui.WithColor(true)), + } + if _, err := runIngestionRun(context.Background(), io.Discard, a, target, []byte("yaml"), spec); err != nil { + t.Fatalf("runIngestionRun: %v", err) + } + out := buf.String() + if !strings.Contains(out, "Connecting to your workspace to submit the run") { + t.Errorf("submit-connect wait is missing its status message:\n%q", out) + } + // A spinner redraws/clears its line with a carriage return; a plain Infof + // never would. This is what distinguishes "shows a spinner" from "prints a + // line". On Windows the spinner is deliberately a static one-liner with no + // \r redraw (ui.Printer.Spinner sidesteps escape garbage on legacy + // consoles), so the redraw is only guaranteed off Windows. + if runtime.GOOS != "windows" && !strings.Contains(out, "\r") { + t.Errorf("submit-connect wait didn't render as a spinner (no \\r redraw):\n%q", out) + } +} + // TestSeamsWiredToRealFns guards that the indirection didn't accidentally // leave a seam nil (a nil seam would panic the money path in production). func TestSeamsWiredToRealFns(t *testing.T) { diff --git a/internal/push/stage.go b/internal/push/stage.go index 8cbc9172..e25d24c2 100644 --- a/internal/push/stage.go +++ b/internal/push/stage.go @@ -50,7 +50,7 @@ type StageOptions struct { Progress Progress // Out is where Stage prints non-progress diagnostic output - // (orphan warnings, upload-channel status, etc.). Nil = io.Discard. + // (orphan warnings, copy-channel status, etc.). Nil = io.Discard. // Progress bar output is separate — schollz writes to its own // configured writer (typically the same one). Out io.Writer @@ -106,7 +106,7 @@ func Stage(ctx context.Context, opts StageOptions) error { if err != nil { return err } - _, _ = fmt.Fprintf(opts.Out, "Opened a secure upload channel to your workspace.\n") + _, _ = fmt.Fprintf(opts.Out, "Opened a secure channel to your workspace's storage.\n") // 3. Defer cleanup. The deferred call uses a FRESH context with // its own deadline — if the parent ctx is cancelled (SIGINT, @@ -130,14 +130,14 @@ func Stage(ctx context.Context, opts StageOptions) error { // accept exec). Times out at StagePodReadyTimeout with // diagnostic hints from container statuses (image pull, // scheduling) when we have them. - _, _ = fmt.Fprintf(opts.Out, "Preparing the upload channel (up to %s)…\n", StagePodReadyTimeout) + _, _ = fmt.Fprintf(opts.Out, "Preparing the copy (up to %s)…\n", StagePodReadyTimeout) if _, err := WaitForStagePodReady(ctx, opts.Client, opts.Namespace, podName); err != nil { return err } // 5. Stream the tar. This is where actual bytes flow. The // progress bar (if TTY) renders during this call. - _, _ = fmt.Fprintf(opts.Out, "Uploading %d files (%s) into %q…\n", + _, _ = fmt.Fprintf(opts.Out, "Copying %d files (%s) into %q…\n", opts.Layout.FileCount(), HumanBytes(opts.Layout.TotalBytes), opts.Table) if err := StreamLayout(ctx, opts.Executor, @@ -147,7 +147,7 @@ func Stage(ctx context.Context, opts StageOptions) error { } // 6. Print "done" message. The deferred cleanup runs after this. - _, _ = fmt.Fprintf(opts.Out, "Uploaded %d files into %q.\n", + _, _ = fmt.Fprintf(opts.Out, "Copied %d files into %q.\n", opts.Layout.FileCount(), opts.Table) return nil } diff --git a/internal/push/stage_test.go b/internal/push/stage_test.go index c43adcd9..8de0bacd 100644 --- a/internal/push/stage_test.go +++ b/internal/push/stage_test.go @@ -82,7 +82,7 @@ func TestStage_HappyPath(t *testing.T) { // Diagnostic output: customer should see the lifecycle // breadcrumbs. Pin a few key phrases so a regression that // silences them is caught. - for _, want := range []string{"Opened a secure upload channel", "Preparing the upload channel", "Uploading", "Uploaded"} { + for _, want := range []string{"Opened a secure channel", "Preparing the copy", "Copying", "Copied"} { if !strings.Contains(out.String(), want) { t.Errorf("output missing %q:\n%s", want, out.String()) } diff --git a/internal/submit/submit.go b/internal/submit/submit.go index adc03c68..e25681b4 100644 --- a/internal/submit/submit.go +++ b/internal/submit/submit.go @@ -96,11 +96,6 @@ func Run(ctx context.Context, opts Options) (*Result, error) { return nil, fmt.Errorf("building submit request: %w", err) } - resp, err := opts.Submitter.Submit(ctx, req) - if err != nil { - return nil, err - } - // A Printer for the human-facing status lines (spinner, detach // notes, summary). nil-safe fallback so callers that didn't thread // the --plain decision still get sensible auto-detected rendering. @@ -109,6 +104,19 @@ func Run(ctx context.Context, opts Options) (*Result, error) { p = ui.New(opts.Out) } + // The POST validates synchronously server-side (schema re-check, + // idempotency lookup, Job creation) up to SubmitTimeout (30s) — the + // single longest blocking wait on the submit path. It lives here rather + // than in the caller, so its progress does too: a spinner keeps the wait + // from sitting silent. Stopped before the announcement below so the two + // don't fight over the line. + submitSpin := p.Spinner("Submitting the run", "") + resp, err := opts.Submitter.Submit(ctx, req) + submitSpin.Stop() + if err != nil { + return nil, err + } + // Submission announcement. Customers see this whether or not // --detach is set. The raw namespace/job identifiers are kept only // where they're actionable (detach + replay, which need them to From 4ea80cc8929f23212b028f625f95005607763c81 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:02:30 +0200 Subject: [PATCH 02/22] =?UTF-8?q?feat(data=20ingest):=20rename=20--categor?= =?UTF-8?q?y/--table/--intent=20=E2=86=92=20--task/--name/--split=20(#180a?= =?UTF-8?q?)=20(#197)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(data ingest): rename --category/--table/--intent to --task/--name/--split (#180a) RFC-0002 §6 flag renames — the mechanical half of #180 (the data-first flow inversion is #180b, stacked separately). Every old flag stays on as a HIDDEN deprecated alias so existing scripts don't break; the wire/spec field names (category/table/intent) are unchanged. - --category → --task, and DROP the old image_classification default: omitting the task now drives the interactive picker (TTY) or a clear "which task? pass --task" error (non-interactive), never a silent image assumption. - --table → --name (the wire field stays "table"). - --intent → --split, default "train"; the prompt is reworded to "Is this training or test data?". - Reworded the interactive prompts, the Review labels, the pre-flight "Ingest settings" labels, all help/error copy, and the README example to the new names. Behavior is otherwise identical — exit codes, guards, and the destination-exists (exit 6) check are unchanged. Tests: the canonical flags work; each old flag still resolves via its hidden alias (canonical wins on conflict); omitting --task errors clearly off a TTY; omitting --split defaults to train. Co-Authored-By: Claude Opus 4.8 * fix(data ingest): keep --intent canonical + mirror the ingestor's table-name rule Reviewer feedback on #180a: - Revert the --intent→--split rename. --intent stays the canonical flag; --split is dropped entirely. The --category→--task and --table→--name renames (with hidden deprecated aliases) are unchanged. Omitting --intent still defaults to train (RFC-0002 §5); the wire/spec field stays "intent". - Tighten table-name validation to mirror the ingestor (the source of truth): names must start with a letter or underscore (^[A-Za-z_][A-Za-z0-9_]*$), matching data-ingestors' validators/table_name_validator.py. The old pattern was looser and let leading-digit / all-digit names ("123", "1data") through the CLI only for the cluster to reject them post-upload. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- README.md | 6 +- internal/cli/data.go | 100 +++++++++++++++++------- internal/cli/data_test.go | 127 +++++++++++++++++++++++-------- internal/cli/interactive.go | 20 ++--- internal/cli/interactive_test.go | 18 ++--- internal/push/category.go | 4 +- internal/push/spec.go | 28 ++++--- internal/push/spec_test.go | 21 ++++- 8 files changed, 230 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 8cbd409c..9dd71ff6 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,9 @@ irm https://github.com/tracebloc/cli/releases/latest/download/install.ps1 | iex # Per dataset tracebloc data ingest ./my-data \ - --table cats_dogs_train \ - --category image_classification \ - --intent train \ + --name cats_dogs_train \ + --task image_classification \ + --split train \ --label-column label ``` diff --git a/internal/cli/data.go b/internal/cli/data.go index f1912d73..fab42ed8 100644 --- a/internal/cli/data.go +++ b/internal/cli/data.go @@ -91,8 +91,15 @@ func newDataIngestCmd() *cobra.Command { // Ingest-spec flags. image_classification + the tabular / // time-series family are supported today; text + detection + // segmentation land in later increments. - table string - category string + // + // --name/--task are the canonical flags (#180); --table/--category + // stay on as hidden deprecated aliases so existing scripts keep + // working. --intent is unchanged. The wire/spec field names don't + // change — this is a CLI-surface rename only. + name string + tableAlias string + task string + categoryAlias string intent string labelColumn string targetSize string @@ -131,9 +138,9 @@ func newDataIngestCmd() *cobra.Command { Short: "Stage a local dataset into your client's storage", Long: `Stages a local dataset into your client's shared storage, submits an ingestion run to jobs-manager, and watches the ingestor Job -to completion. Supports 9 task categories (image classification, +to completion. Supports 9 tasks (image classification, object/keypoint detection, text classification, masked language -modeling, and the tabular / time-series family); pick one with --category. +modeling, and the tabular / time-series family); pick one with --task. Expected local layout (image_classification shown): @@ -155,13 +162,13 @@ see tracebloc/client#147 non-goals. Exit codes: 0 files staged + ingested successfully (or --detach: just staged + submitted) 2 schema validation failed (synthesized spec rejected) or - v0.1-unsupported category passed + v0.1-unsupported task passed 3 local-layout or kubeconfig error 4 cluster reachable but no tracebloc client / shared storage missing 5 ingestor SA token couldn't be obtained, or jobs-manager rejected the token (401/403) 6 destination table already exists (re-run with --overwrite to - replace it, or pick a different --table) + replace it, or pick a different --name) 7 pre-flight succeeded but staging the files failed (Pod creation, image pull, exec stream, or remote tar error) — or, with --overwrite, removing the old table failed @@ -174,6 +181,24 @@ Exit codes: if len(args) > 0 { localPath = args[0] } + // Resolve the deprecated flag aliases (#180): the canonical + // flag wins; a hidden legacy alias fills in only when the new + // flag wasn't passed, so old scripts keep working without the + // new surface silently shadowing them. The wire/spec field + // names are unchanged — this is a CLI rename only. + nameVal := name + if cmd.Flags().Changed("table") && !cmd.Flags().Changed("name") { + nameVal = tableAlias + } + taskVal := task + if cmd.Flags().Changed("category") && !cmd.Flags().Changed("task") { + taskVal = categoryAlias + } + // Whether the task was chosen at all (via either spelling). + // Dropping --task's old image_classification default means an + // unset task now drives the picker (TTY) or a clear error + // (non-interactive), never a silent image assumption. + taskSet := cmd.Flags().Changed("task") || cmd.Flags().Changed("category") // Guided mode: on a terminal (and unless --no-input), prompt // for whatever's still missing. Off a TTY / with --no-input, // prompter stays nil and runDataIngest keeps flag-only @@ -200,7 +225,7 @@ Exit codes: Context: contextOverride, Namespace: nsOverride, Spec: push.SpecArgs{ - Table: table, Category: category, Intent: intent, + Table: nameVal, Category: taskVal, Intent: intent, LabelColumn: labelColumn, LabelPolicy: labelPolicy, TimeColumn: timeColumn, NumberOfKeypoints: numberOfKeypoints, }, @@ -215,7 +240,7 @@ Exit codes: Printer: printer, Interactive: interactive, Prompter: pr, - CategorySet: cmd.Flags().Changed("category"), + TaskSet: taskSet, OutputJSON: outputJSON, JSONOut: jsonOut, }) @@ -234,16 +259,23 @@ Exit codes: // pre-empts our richer schema-driven diagnostic. Instead, the // schema validator catches missing/empty values with the canonical // JSON-pointer-anchored error. - cmd.Flags().StringVar(&table, "table", "", - "destination table name (MySQL identifier; matches /data/shared// on the PVC)") - cmd.Flags().StringVar(&category, "category", "image_classification", - "task category, one of: "+push.SupportedCategoriesList()) + cmd.Flags().StringVar(&name, "name", "", + "a name for this dataset (letters, digits, underscore) — you'll reference it by this name when you start a training run") + cmd.Flags().StringVar(&tableAlias, "table", "", + "deprecated alias for --name") + _ = cmd.Flags().MarkHidden("table") + cmd.Flags().StringVar(&task, "task", "", + "the task this data is for, one of: "+push.SupportedCategoriesList()+ + ". Omit it on a terminal to pick interactively.") + cmd.Flags().StringVar(&categoryAlias, "category", "", + "deprecated alias for --task") + _ = cmd.Flags().MarkHidden("category") cmd.Flags().StringVar(&intent, "intent", "", - "intent: train|test") + "is this training or test data? train|test (default train)") cmd.Flags().StringVar(&labelColumn, "label-column", "", - "name of the label/target column (in labels.csv for image categories, in the data CSV for tabular)") + "name of the label/target column (in labels.csv for image tasks, in the data CSV for tabular)") cmd.Flags().StringVar(&targetSize, "target-size", "", - "image categories only: resolution as WxH (e.g. 512x512). Default: auto-detected from the first image. "+ + "image tasks only: resolution as WxH (e.g. 512x512). Default: auto-detected from the first image. "+ "All images must share this resolution — the ingestor validates it, it does not resize.") cmd.Flags().StringVar(&schemaFlag, "schema", "", "tabular/time-series only: column types as col:TYPE,col:TYPE (e.g. age:INT,price:FLOAT). "+ @@ -304,12 +336,13 @@ type runDataIngestArgs struct { // Interactive guided mode (#28). When Interactive is true, // runDataIngest prompts (via Prompter) for any missing core inputs - // before validation. CategorySet records whether --category was - // passed explicitly (its non-empty default would otherwise look - // like a deliberate choice). Prompter is nil off a TTY / --no-input. + // before validation. TaskSet records whether the task was passed + // explicitly (via --task or the hidden --category alias); an unset + // task drives the picker rather than assuming a default. Prompter is + // nil off a TTY / --no-input. Interactive bool Prompter prompter - CategorySet bool + TaskSet bool // OutputJSON routes human output to stderr and emits a JSON result // to JSONOut (stdout); set together by the RunE in --output-json @@ -398,7 +431,7 @@ collaborators can train against that table without ever seeing the raw files.`)) // validation. Flags already provided win; non-TTY / --no-input // leaves Prompter nil and skips straight to the flag-only path. if a.Interactive && a.Prompter != nil { - if err := runInteractive(a.Printer, a.Prompter, &a, a.CategorySet); err != nil { + if err := runInteractive(a.Printer, a.Prompter, &a, a.TaskSet); err != nil { if errors.Is(err, errInteractiveCancelled) { a.Printer.Infof("Cancelled — nothing was ingested.") return nil @@ -406,6 +439,13 @@ collaborators can train against that table without ever seeing the raw files.`)) return &exitError{code: 3, err: fmt.Errorf("interactive setup: %w", err)} } } + // --intent defaults to train. Applied after the interactive block so + // the guided flow still asks "training or test?" (it prompts on an + // empty value); a non-interactive run that omits --intent gets train + // without erroring (RFC-0002 §5). The wire field stays "intent". + if a.Spec.Intent == "" { + a.Spec.Intent = "train" + } if a.LocalPath == "" { return &exitError{code: 3, err: errors.New( "local dataset path is required — pass it as an argument, or run " + @@ -442,8 +482,14 @@ collaborators can train against that table without ever seeing the raw files.`)) // list rather than the schema's 11-option enum dump. switch { case a.Spec.Category == "": - // Left empty by a caller; let the schema produce the canonical - // "category is required" error downstream. + // No task chosen. In guided mode the picker already filled this; + // reaching here means a non-interactive run (or --no-input / + // --output-json) that omitted --task. Give a clear, actionable + // error instead of silently assuming images (the old default). + return &exitError{code: 2, err: fmt.Errorf( + "which task is this data for? pass --task — one of: %s. "+ + "(On a terminal without --no-input, tracebloc asks you to pick.)", + push.SupportedCategoriesList())} case push.IsCLISupported(a.Spec.Category): // supported case push.IsKnown(a.Spec.Category): @@ -455,11 +501,11 @@ collaborators can train against that table without ever seeing the raw files.`)) // caught above, so IsKnown here means known-but-unsupported. spec, _ := push.Lookup(a.Spec.Category) return &exitError{code: 2, err: fmt.Errorf( - "category %q isn't supported by the CLI yet (%s). Supported categories: %s.", + "task %q isn't supported by the CLI yet (%s). Supported tasks: %s.", a.Spec.Category, spec.UnsupportedNote, push.SupportedCategoriesList())} default: return &exitError{code: 2, err: fmt.Errorf( - "category %q isn't a recognized task category. Supported categories: %s.", + "task %q isn't a recognized task. Supported tasks: %s.", a.Spec.Category, push.SupportedCategoriesList())} } @@ -681,7 +727,7 @@ collaborators can train against that table without ever seeing the raw files.`)) return &exitError{code: 6, err: fmt.Errorf( "table %q already exists in this client. Re-ingesting the same table doesn't merge or replace — "+ "the run would fail after uploading everything. Re-run with --overwrite to replace it, "+ - "or pick a different --table. (`tracebloc data delete %s` also removes it.)", + "or pick a different --name. (`tracebloc data delete %s` also removes it.)", existingTable, existingTable)} } if tableExists && a.Overwrite { @@ -1024,8 +1070,8 @@ func printLocalSummary(p *ui.Printer, layout *push.LocalLayout, spec map[string] p.Field("total size", push.HumanBytes(layout.TotalBytes)) p.Section("Ingest settings") - p.Field("table", fmt.Sprintf("%v", spec["table"])) - p.Field("category", fmt.Sprintf("%v", spec["category"])) + p.Field("name", fmt.Sprintf("%v", spec["table"])) + p.Field("task", fmt.Sprintf("%v", spec["category"])) p.Field("intent", fmt.Sprintf("%v", spec["intent"])) switch lbl := spec["label"].(type) { case string: diff --git a/internal/cli/data_test.go b/internal/cli/data_test.go index 49716e1c..0796307b 100644 --- a/internal/cli/data_test.go +++ b/internal/cli/data_test.go @@ -101,13 +101,13 @@ func TestDataIngest_UnsupportedCategory_ExitsTwo(t *testing.T) { t.Run(badCategory, func(t *testing.T) { code, _, _ := execDataIngest(t, []string{ root, - "--table=t1", - "--category=" + badCategory, + "--name=t1", + "--task=" + badCategory, "--intent=train", "--label-column=label", }) if code != 2 { - t.Fatalf("expected exit 2 for unsupported category %q, got %d", badCategory, code) + t.Fatalf("expected exit 2 for unsupported task %q, got %d", badCategory, code) } }) } @@ -126,18 +126,18 @@ func TestDataIngest_KnownUnsupportedCategory_PendingNote(t *testing.T) { rootCmd.SetErr(&bytes.Buffer{}) rootCmd.SetArgs([]string{"data", "ingest", "--kubeconfig=/tmp/tracebloc-cli-test-nonexistent-" + t.Name(), - root, "--table=t1", "--category=causal_language_modeling", + root, "--name=t1", "--task=causal_language_modeling", "--intent=train", "--label-column=label"}) err := rootCmd.Execute() if err == nil { - t.Fatal("expected an error for a known-but-unsupported category") + t.Fatal("expected an error for a known-but-unsupported task") } if got := ExitCodeFromError(err); got != 2 { t.Fatalf("exit code = %d, want 2", got) } msg := err.Error() - if strings.Contains(msg, "isn't a recognized task category") { - t.Errorf("known category misrouted to the unrecognized-category branch:\n%s", msg) + if strings.Contains(msg, "isn't a recognized task") { + t.Errorf("known task misrouted to the unrecognized-task branch:\n%s", msg) } if !strings.Contains(msg, "isn't supported by the CLI yet") { t.Errorf("want the registry pending-support note, got:\n%s", msg) @@ -145,9 +145,9 @@ func TestDataIngest_KnownUnsupportedCategory_PendingNote(t *testing.T) { } // TestDataIngest_TraversalTableName_ExitsTwo is the security -// regression pin at the CLI layer. --table=../../etc must be +// regression pin at the CLI layer. --name=../../etc must be // rejected with exit 2 BEFORE any spec synthesis or cluster work — -// the table name flows into the /data/shared/
/ PVC path, +// the name flows into the /data/shared/
/ PVC path, // and a traversal value would let PR-b's stage Pod escape that // subtree. Bugbot flagged this on PR #8 commit 4240097. func TestDataIngest_TraversalTableName_ExitsTwo(t *testing.T) { @@ -156,8 +156,8 @@ func TestDataIngest_TraversalTableName_ExitsTwo(t *testing.T) { t.Run(bad, func(t *testing.T) { code, _, _ := execDataIngest(t, []string{ root, - "--table=" + bad, - "--category=image_classification", + "--name=" + bad, + "--task=image_classification", "--intent=train", "--label-column=label", }) @@ -168,23 +168,23 @@ func TestDataIngest_TraversalTableName_ExitsTwo(t *testing.T) { } } -// TestDataIngest_MissingIntent_ExitsTwo: pins the "intent is -// required" diagnostic path — different schema violation but the -// same exit-code class. -func TestDataIngest_MissingIntent_ExitsTwo(t *testing.T) { +// TestDataIngest_OmittedIntent_DefaultsToTrain: --intent defaults to +// "train", so omitting it no longer fails schema validation (exit 2). +// The run gets past the spec checks and stops at the injected bad +// kubeconfig (exit 3) — the same fall-through point as +// TestDataIngest_BadKubeconfig_ExitsThree, which proves the default was +// applied rather than the value being rejected as missing. +func TestDataIngest_OmittedIntent_DefaultsToTrain(t *testing.T) { root := imgcLayout(t) - code, _, stderr := execDataIngest(t, []string{ + code, _, _ := execDataIngest(t, []string{ root, - "--table=t1", - "--category=image_classification", - // intent omitted + "--name=t1", + "--task=image_classification", + // intent omitted → defaults to train "--label-column=label", }) - if code != 2 { - t.Fatalf("expected exit 2 for missing intent, got %d", code) - } - if !strings.Contains(stderr, "intent") { - t.Errorf("expected stderr to mention 'intent', got:\n%s", stderr) + if code != 3 { + t.Fatalf("expected exit 3 (default intent applied, then bad kubeconfig), got %d", code) } } @@ -202,8 +202,8 @@ func TestDataIngest_MissingIntent_ExitsTwo(t *testing.T) { func TestDataIngest_NonexistentLocalPath_ExitsThree(t *testing.T) { code, _, _ := execDataIngest(t, []string{ "/tmp/tracebloc-cli-test-no-such-dir-" + t.Name(), - "--table=t1", - "--category=image_classification", + "--name=t1", + "--task=image_classification", "--intent=train", "--label-column=label", }) @@ -230,8 +230,8 @@ func TestDataIngest_MissingLabelsCSV_ExitsThree(t *testing.T) { code, _, _ := execDataIngest(t, []string{ root, - "--table=t1", - "--category=image_classification", + "--name=t1", + "--task=image_classification", "--intent=train", "--label-column=label", }) @@ -249,8 +249,8 @@ func TestDataIngest_BadKubeconfig_ExitsThree(t *testing.T) { root := imgcLayout(t) code, _, _ := execDataIngest(t, []string{ root, - "--table=t1", - "--category=image_classification", + "--name=t1", + "--task=image_classification", "--intent=train", "--label-column=label", }) @@ -270,7 +270,7 @@ func TestDataIngest_RequiresExactlyOneArg(t *testing.T) { { name: "no positional", args: []string{ - "--table=t1", "--category=image_classification", + "--name=t1", "--task=image_classification", "--intent=train", "--label-column=label", }, }, @@ -278,7 +278,7 @@ func TestDataIngest_RequiresExactlyOneArg(t *testing.T) { name: "two positionals", args: []string{ "./a", "./b", - "--table=t1", "--category=image_classification", + "--name=t1", "--task=image_classification", "--intent=train", "--label-column=label", }, }, @@ -293,6 +293,69 @@ func TestDataIngest_RequiresExactlyOneArg(t *testing.T) { } } +// TestDataIngest_DeprecatedFlagAliases pins that the pre-#180 flag names +// still resolve through their hidden aliases so existing scripts don't +// break: --table→--name and --category→--task. A valid +// spec via the old names must fall through the local checks to the +// injected bad kubeconfig (exit 3) exactly as the canonical names do; a +// bad value via --category must still reach the task gate (exit 2), +// proving the aliased value flows through rather than being ignored. +func TestDataIngest_DeprecatedFlagAliases(t *testing.T) { + root := imgcLayout(t) + + t.Run("valid via old names falls through to kubeconfig", func(t *testing.T) { + code, _, _ := execDataIngest(t, []string{ + root, + "--table=t1", + "--category=image_classification", + "--intent=train", + "--label-column=label", + }) + if code != 3 { + t.Fatalf("expected exit 3 (aliases resolved, then bad kubeconfig), got %d", code) + } + }) + + t.Run("bad value via --category reaches the task gate", func(t *testing.T) { + code, _, _ := execDataIngest(t, []string{ + root, + "--table=t1", + "--category=definitely-not-a-task", + "--intent=train", + "--label-column=label", + }) + if code != 2 { + t.Fatalf("expected exit 2 (aliased task value hit the gate), got %d", code) + } + }) +} + +// TestDataIngest_OmitTask_NonInteractive_Errors: dropping --task's old +// image_classification default means a non-interactive run that omits the +// task no longer silently assumes images. Off a TTY (as in tests) the +// picker can't run, so the task gate returns a clear exit-2 error naming +// --task. execDataIngest discards the error, so run the command directly +// and inspect it (mirrors TestDataIngest_KnownUnsupportedCategory_PendingNote). +func TestDataIngest_OmitTask_NonInteractive_Errors(t *testing.T) { + root := imgcLayout(t) + rootCmd := NewRootCmd(BuildInfo{Version: "test"}) + rootCmd.SetOut(&bytes.Buffer{}) + rootCmd.SetErr(&bytes.Buffer{}) + rootCmd.SetArgs([]string{"data", "ingest", + "--kubeconfig=/tmp/tracebloc-cli-test-nonexistent-" + t.Name(), + root, "--name=t1", "--intent=train", "--label-column=label"}) + err := rootCmd.Execute() + if err == nil { + t.Fatal("expected an error when --task is omitted non-interactively") + } + if got := ExitCodeFromError(err); got != 2 { + t.Fatalf("exit code = %d, want 2", got) + } + if !strings.Contains(err.Error(), "--task") { + t.Errorf("error should tell the user to pass --task, got:\n%s", err.Error()) + } +} + // TestAliasResolution verifies that the deprecated aliases still dispatch // to the same handlers as the canonical names: // - "dataset" → same as "data" diff --git a/internal/cli/interactive.go b/internal/cli/interactive.go index d945de91..3b561008 100644 --- a/internal/cli/interactive.go +++ b/internal/cli/interactive.go @@ -104,13 +104,13 @@ func isInteractiveTTY() bool { // runInteractive fills the gaps in a's core ingest fields by prompting, // then returns. It only prompts for what's still missing, so flags the -// user already passed win. categorySet says whether --category was set -// explicitly (vs left at its non-empty default), which would otherwise -// hide "the user didn't actually choose a category." +// user already passed win. taskSet says whether the task was passed +// explicitly (via --task or the hidden --category alias); when it wasn't, +// the picker runs rather than assuming a default. // -// Mutates a through the pointer. PR-b adds category-specific prompts +// Mutates a through the pointer. PR-b adds task-specific prompts // (target-size, schema, number-of-keypoints) + a confirm screen. -func runInteractive(p *ui.Printer, pr prompter, a *runDataIngestArgs, categorySet bool) error { +func runInteractive(p *ui.Printer, pr prompter, a *runDataIngestArgs, taskSet bool) error { p.PromptHeader("Let's set up your data ingest") p.Hintf("Press Enter to accept a default; Ctrl-C to cancel.") prompted := false @@ -125,9 +125,9 @@ func runInteractive(p *ui.Printer, pr prompter, a *runDataIngestArgs, categorySe prompted = true } - if !categorySet { + if !taskSet { p.PromptHint("What kind of task your data is for — this drives how it's validated and loaded.") - ans, err := pr.Select("Task category", "what kind of data this is", + ans, err := pr.Select("Task", "what kind of data this is", promptCategories, a.Spec.Category) if err != nil { return err @@ -150,7 +150,7 @@ func runInteractive(p *ui.Printer, pr prompter, a *runDataIngestArgs, categorySe if a.Spec.Intent == "" { p.PromptHint("Whether this split is used to train the model or to evaluate it.") - ans, err := pr.Select("Intent", "which split this data is", + ans, err := pr.Select("Is this training or test data?", "which split this data is", []string{"train", "test"}, "train") if err != nil { return err @@ -262,8 +262,8 @@ func promptCategorySpecific(p *ui.Printer, pr prompter, a *runDataIngestArgs) (b func renderReview(p *ui.Printer, a *runDataIngestArgs) { p.Section("Review") p.Field("path", a.LocalPath) - p.Field("category", a.Spec.Category) - p.Field("table", a.Spec.Table) + p.Field("task", a.Spec.Category) + p.Field("name", a.Spec.Table) p.Field("intent", a.Spec.Intent) if a.Spec.LabelColumn != "" { p.Field("label column", a.Spec.LabelColumn) diff --git a/internal/cli/interactive_test.go b/internal/cli/interactive_test.go index d1daf298..18b24d40 100644 --- a/internal/cli/interactive_test.go +++ b/internal/cli/interactive_test.go @@ -56,9 +56,9 @@ func discardPrinter() *ui.Printer { return ui.New(&bytes.Buffer{}) } func TestRunInteractive_FillsAllWhenEmpty(t *testing.T) { f := &fakePrompter{answers: map[string]string{ "Path to your dataset directory": "./data", - "Task category": "tabular_classification", + "Task": "tabular_classification", "Destination table name": "churn_train", - "Intent": "test", + "Is this training or test data?": "test", "Label column": "churned", }} a := &runDataIngestArgs{Spec: push.SpecArgs{Category: "image_classification"}} @@ -96,7 +96,7 @@ func TestRunInteractive_ShowsExampleHints(t *testing.T) { var buf bytes.Buffer p := ui.New(&buf, ui.WithColor(false)) - if err := runInteractive(p, f, a, true /*categorySet*/); err != nil { + if err := runInteractive(p, f, a, true /*taskSet*/); err != nil { t.Fatalf("runInteractive: %v", err) } out := buf.String() @@ -113,18 +113,18 @@ func TestRunInteractive_ShowsExampleHints(t *testing.T) { } // TestRunInteractive_SkipsProvidedValues: flags already set (and an -// explicit --category) mean nothing is prompted. +// explicit --task) mean nothing is prompted. func TestRunInteractive_SkipsProvidedValues(t *testing.T) { f := &fakePrompter{answers: map[string]string{}} - // text_classification has no category-specific prompts, so with all - // core fields set + an explicit --category, nothing is asked. + // text_classification has no task-specific prompts, so with all + // core fields set + an explicit --task, nothing is asked. a := &runDataIngestArgs{ LocalPath: "./data", Spec: push.SpecArgs{ Category: "text_classification", Table: "t", Intent: "train", LabelColumn: "label", }, } - if err := runInteractive(discardPrinter(), f, a, true /*categorySet*/); err != nil { + if err := runInteractive(discardPrinter(), f, a, true /*taskSet*/); err != nil { t.Fatalf("runInteractive: %v", err) } if len(f.asked) != 0 { @@ -192,8 +192,8 @@ func TestRunInteractive_Cancel(t *testing.T) { // label column, so it must not be prompted. func TestRunInteractive_MLMSkipsLabel(t *testing.T) { f := &fakePrompter{answers: map[string]string{ - "Destination table name": "mlm_train", - "Intent": "train", + "Destination table name": "mlm_train", + "Is this training or test data?": "train", }} a := &runDataIngestArgs{ LocalPath: "./data", diff --git a/internal/push/category.go b/internal/push/category.go index eef4a2e3..32fa2e92 100644 --- a/internal/push/category.go +++ b/internal/push/category.go @@ -10,7 +10,7 @@ import "strings" // with what the ingestor actually resolves. // // Everything category-shaped derives from the registry below — the -// family predicates, the `--category` help text, the interactive +// family predicates, the `--task` help text, the interactive // picker, and the push accept-gate — so the enumerations can't drift // apart (they used to: the flag help listed 5 of 9, cli#74). type CategorySpec struct { @@ -130,7 +130,7 @@ func IsText(category string) bool { func IsRegressionClass(category string) bool { return categoryByID[category].RegressionClass } // SupportedCategoryIDs returns the ids `dataset push` supports, in display -// order. Used to build the --category help, the interactive picker, and +// order. Used to build the --task help, the interactive picker, and // the accept-gate's "Supported:" lists from one place. func SupportedCategoryIDs() []string { ids := make([]string, 0, len(categoryRegistry)) diff --git a/internal/push/spec.go b/internal/push/spec.go index 1d485c10..dbee3fe0 100644 --- a/internal/push/spec.go +++ b/internal/push/spec.go @@ -30,15 +30,22 @@ import ( // 2. A safe single path segment — the name becomes the // /data/shared/
/ subdirectory on the PVC. // -// The intersection of "MySQL identifier" and "single safe path -// component" is [A-Za-z0-9_]: letters, digits, underscore. No -// slashes, no dots — which is what closes the path-traversal hole -// (see ValidateTableName). +// The name must START with a letter or underscore, then letters, +// digits, and underscores: [A-Za-z_][A-Za-z0-9_]*. No slashes, no +// dots — which is what closes the path-traversal hole (see +// ValidateTableName) — and no leading digit. +// +// This mirrors the ingestor's own check (the source of truth): +// tracebloc/data-ingestors' validators/table_name_validator.py +// requires ^[a-zA-Z_][a-zA-Z0-9_]*$. Any name accepted here is +// therefore accepted in-cluster; the looser old pattern let +// leading-digit / all-digit names ("123", "1data") through the CLI +// only for the cluster to reject them post-upload. // // All the real-world example tables (chest_xrays_train, // cats_dogs_train) match this; it's the conventional snake_case // table-naming style anyway. -var tableNamePattern = regexp.MustCompile(`^[A-Za-z0-9_]+$`) +var tableNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) // MaxTableNameLength caps `--table` at 63 chars. Two hard limits // agree on this: @@ -74,7 +81,7 @@ const MaxTableNameLength = 63 // both of which assume a validated name. func ValidateTableName(table string) error { if table == "" { - return fmt.Errorf("table name is required (set --table)") + return fmt.Errorf("dataset name is required (set --name)") } if len(table) > MaxTableNameLength { return fmt.Errorf( @@ -86,12 +93,13 @@ func ValidateTableName(table string) error { } if !tableNamePattern.MatchString(table) { return fmt.Errorf( - "table name %q is invalid: must match [A-Za-z0-9_]+ "+ - "(letters, digits, underscore only). The table name is "+ + "table name %q is invalid: must start with a letter or "+ + "underscore, then letters, digits, and underscores only "+ + "(matches [A-Za-z_][A-Za-z0-9_]*). The table name is "+ "used both as the MySQL table identifier and as the "+ "/data/shared/
/ subdirectory on the cluster PVC, "+ - "so slashes, dots, and path-traversal sequences are "+ - "rejected.", + "so a leading digit, slashes, dots, and path-traversal "+ + "sequences are rejected.", table) } return nil diff --git a/internal/push/spec_test.go b/internal/push/spec_test.go index 72d8a791..27c7cb1d 100644 --- a/internal/push/spec_test.go +++ b/internal/push/spec_test.go @@ -368,7 +368,6 @@ func TestValidateTableName_Accepts(t *testing.T) { "ABC", "table_123", "_leading_underscore", - "9starts_with_digit", // valid MySQL identifier + safe path segment } { if err := ValidateTableName(name); err != nil { t.Errorf("ValidateTableName(%q) = %v, want nil", name, err) @@ -376,6 +375,26 @@ func TestValidateTableName_Accepts(t *testing.T) { } } +// TestValidateTableName_LeadingDigit mirrors the ingestor's table-name +// rule (tracebloc/data-ingestors' validators/table_name_validator.py, +// ^[a-zA-Z_][a-zA-Z0-9_]*$, the source of truth): a name must start +// with a letter or underscore. The old CLI pattern was looser and let +// leading-digit / all-digit names through only for the cluster to +// reject them post-upload — this pins the CLI to the ingestor so any +// name accepted here is accepted in-cluster. +func TestValidateTableName_LeadingDigit(t *testing.T) { + for _, name := range []string{"1data", "123"} { + if err := ValidateTableName(name); err == nil { + t.Errorf("ValidateTableName(%q) = nil, want a leading-digit rejection", name) + } + } + for _, name := range []string{"_data", "Data1", "chest_xrays_train"} { + if err := ValidateTableName(name); err != nil { + t.Errorf("ValidateTableName(%q) = %v, want nil", name, err) + } + } +} + // TestValidateTableName_RejectsTooLong: K8s label values are // capped at 63 chars, and the stage Pod carries the raw table // name as the tracebloc.io/table label. Without this rejection, From e5f27207e0fb2d03ab6713abaac8bef8491029ed Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:05:51 +0200 Subject: [PATCH 03/22] fix(data): drop the tokenizer.json ingest requirement for MLM (#184) (#195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit masked_language_modeling required a tokenizer.json at the dataset root, staged it as an ExtraFile, and displayed it in the local summary. This was a false gate: the ingestor never read it, the ingest.v1 schema never required it, and the training side deliberately stopped staging a dataset tokenizer in #805 (it diverged the vocab and broke weight averaging). The tokenizer is the collaborator's, supplied at model upload. - DiscoverText: remove the MLM tokenizer branch. An MLM dataset with just the text layout is now accepted; a stray tokenizer.json is ignored, not an error (it is simply not walked). - LocalLayout.ExtraFiles was populated solely by this branch, so remove it (struct field, FileCount, the stream.go packaging loop, and the data.go local-summary display) rather than leave dead, always-empty plumbing. - Fix the now-false "the ingestor reads it" doc comments. - Tests: replace TestDiscoverText_MLM_RequiresTokenizer with TestDiscoverText_MLM_NoTokenizer (accepted without; stray one ignored). RFC-0002 §12.11. CLI-only, low risk. Co-authored-by: Claude Opus 4.8 --- internal/cli/data.go | 3 --- internal/push/stream.go | 18 +---------------- internal/push/text.go | 41 ++++++++------------------------------ internal/push/text_test.go | 41 ++++++++++++++++++++------------------ internal/push/walk.go | 16 ++++----------- 5 files changed, 35 insertions(+), 84 deletions(-) diff --git a/internal/cli/data.go b/internal/cli/data.go index fab42ed8..b0b1a37b 100644 --- a/internal/cli/data.go +++ b/internal/cli/data.go @@ -1049,9 +1049,6 @@ func printLocalSummary(p *ui.Printer, layout *push.LocalLayout, spec map[string] dir := push.TextSidecarDir(cat) p.Field("labels.csv", layout.LabelsCSV) p.Field(dir, fmt.Sprintf("%d files", len(layout.Sidecars[dir]))) - if _, ok := layout.ExtraFiles["tokenizer.json"]; ok { - p.Field("tokenizer", "tokenizer.json") - } default: p.Field("labels.csv", layout.LabelsCSV) imagesVal := fmt.Sprintf("%d files", len(layout.Images)) diff --git a/internal/push/stream.go b/internal/push/stream.go index d0b194f4..822576b5 100644 --- a/internal/push/stream.go +++ b/internal/push/stream.go @@ -390,22 +390,6 @@ func writeLayoutTar(w io.Writer, layout *LocalLayout) (err error) { } } - // Extra root-level files (e.g. masked_language_modeling's - // tokenizer.json), staged at the table root under their dest name. - // Sorted for deterministic stream order. - for _, dest := range sortedKeys(layout.ExtraFiles) { - n, err := writeTarFile(tw, layout.ExtraFiles[dest], dest) - if err != nil { - return fmt.Errorf("packaging %s: %w", dest, err) - } - totalBytes += n - if totalBytes > MaxTotalBytes { - return fmt.Errorf( - "dataset exceeded v0.1 total cap of %s after streaming %s (reached %s)", - HumanBytes(MaxTotalBytes), dest, HumanBytes(totalBytes)) - } - } - // Generic sidecar directories (texts/, sequences/, and — later — // annotations/, masks/), each staged under "/". // Sorted by dir name for deterministic stream order. @@ -432,7 +416,7 @@ func writeLayoutTar(w io.Writer, layout *LocalLayout) (err error) { } // sortedKeys returns a map's string keys in sorted order, for -// deterministic iteration when packaging ExtraFiles / Sidecars. +// deterministic iteration when packaging Sidecars. func sortedKeys[V any](m map[string]V) []string { ks := make([]string, 0, len(m)) for k := range m { diff --git a/internal/push/text.go b/internal/push/text.go index 163292ea..4d9048b4 100644 --- a/internal/push/text.go +++ b/internal/push/text.go @@ -20,11 +20,13 @@ var textExtensions = map[string]struct{}{ // // - /labels.csv (required) // - //*.txt (required; sidecar = texts | sequences) -// - /tokenizer.json (required for masked_language_modeling) // -// The returned layout stages the CSV (as labels.csv), the text files -// under "/", and — for MLM — tokenizer.json at the table root -// (the ingestor reads it from SRC_PATH/tokenizer.json for [MASK]/[PAD]). +// The returned layout stages the CSV (as labels.csv) and the text files +// under "/". masked_language_modeling needs no tokenizer.json — +// the ingestor never read one, and #805 removed the dataset-staged +// tokenizer (it diverged the vocab and broke weight averaging); the +// collaborator's tokenizer ships at model upload. A tokenizer.json left +// in the directory is simply ignored, not an error. func DiscoverText(category, rootDir string) (*LocalLayout, error) { abs, err := filepath.Abs(rootDir) if err != nil { @@ -40,9 +42,8 @@ func DiscoverText(category, rootDir string) (*LocalLayout, error) { } layout := &LocalLayout{ - Root: abs, - Sidecars: map[string][]string{}, - ExtraFiles: map[string]string{}, + Root: abs, + Sidecars: map[string][]string{}, } dirName := TextSidecarDir(category) @@ -83,32 +84,6 @@ func DiscoverText(category, rootDir string) (*LocalLayout, error) { layout.Sidecars[dirName] = files layout.TotalBytes += sidecarBytes - // masked_language_modeling needs a tokenizer.json at the root. - if category == "masked_language_modeling" { - tokPath := filepath.Join(abs, "tokenizer.json") - tokStat, terr := os.Lstat(tokPath) - if terr != nil { - if errors.Is(terr, os.ErrNotExist) { - return nil, fmt.Errorf( - "missing tokenizer.json in %q. masked_language_modeling requires a "+ - "tokenizer.json (HuggingFace tokenizers format) at the dataset root; "+ - "the ingestor reads it for the [MASK]/[PAD] tokens.", abs) - } - return nil, fmt.Errorf("stat tokenizer.json: %w", terr) - } - if err := rejectSymlink(tokStat, "tokenizer.json"); err != nil { - return nil, err - } - if tokStat.IsDir() { - return nil, fmt.Errorf("%q is a directory, not a file", tokPath) - } - if tokStat.Size() > MaxSingleFileBytes { - return nil, sizeError("tokenizer.json", tokStat.Size(), MaxSingleFileBytes) - } - layout.ExtraFiles["tokenizer.json"] = tokPath - layout.TotalBytes += tokStat.Size() - } - if layout.TotalBytes > MaxTotalBytes { return nil, fmt.Errorf( "dataset is %s, exceeds v0.1 cap of %s. For larger datasets, the "+ diff --git a/internal/push/text_test.go b/internal/push/text_test.go index 66ae6292..472bb450 100644 --- a/internal/push/text_test.go +++ b/internal/push/text_test.go @@ -11,8 +11,9 @@ import ( ) // mkTextDir builds a text-family dataset dir: labels.csv + a sidecar -// directory (texts/ or sequences/) with two .txt files, optionally -// plus a tokenizer.json at the root (for MLM). +// directory (texts/ or sequences/) with two .txt files. withTokenizer +// drops a stray tokenizer.json at the root — DiscoverText must ignore +// it (MLM no longer requires or stages one; see #184 / #805). func mkTextDir(t *testing.T, sidecar string, withTokenizer bool) string { t.Helper() dir := t.TempDir() @@ -47,34 +48,36 @@ func TestDiscoverText_Classification(t *testing.T) { if len(layout.Images) != 0 { t.Errorf("Images should be empty for text, got %v", layout.Images) } - if len(layout.ExtraFiles) != 0 { - t.Errorf("ExtraFiles should be empty for text_classification, got %v", layout.ExtraFiles) - } if got := layout.FileCount(); got != 3 { // labels.csv + 2 texts t.Errorf("FileCount = %d, want 3", got) } } -// TestDiscoverText_MLM_RequiresTokenizer: masked_language_modeling -// errors without tokenizer.json, and stages it as an ExtraFile when -// present (the ingestor reads SRC_PATH/tokenizer.json). -func TestDiscoverText_MLM_RequiresTokenizer(t *testing.T) { - if _, err := DiscoverText("masked_language_modeling", mkTextDir(t, "sequences", false)); err == nil { - t.Error("DiscoverText(MLM) without tokenizer.json returned nil error") - } - - layout, err := DiscoverText("masked_language_modeling", mkTextDir(t, "sequences", true)) +// TestDiscoverText_MLM_NoTokenizer: masked_language_modeling no longer +// requires a tokenizer.json — the ingestor never read one and #805 +// removed the dataset-staged tokenizer. A dataset with just the text +// layout is accepted, and a stray tokenizer.json is ignored (not staged, +// not counted), never an error. +func TestDiscoverText_MLM_NoTokenizer(t *testing.T) { + layout, err := DiscoverText("masked_language_modeling", mkTextDir(t, "sequences", false)) if err != nil { - t.Fatalf("DiscoverText(MLM): %v", err) + t.Fatalf("DiscoverText(MLM) without tokenizer.json: %v", err) } if len(layout.Sidecars["sequences"]) != 2 { t.Errorf("sequences files = %d, want 2", len(layout.Sidecars["sequences"])) } - if layout.ExtraFiles["tokenizer.json"] == "" { - t.Errorf("tokenizer.json not staged as an ExtraFile: %v", layout.ExtraFiles) + if got := layout.FileCount(); got != 3 { // labels.csv + 2 sequences + t.Errorf("FileCount = %d, want 3", got) + } + + // A tokenizer.json left in the directory must be ignored, not staged + // (and not an error). + withTok, err := DiscoverText("masked_language_modeling", mkTextDir(t, "sequences", true)) + if err != nil { + t.Fatalf("DiscoverText(MLM) with a stray tokenizer.json: %v", err) } - if got := layout.FileCount(); got != 4 { // labels.csv + 2 sequences + tokenizer - t.Errorf("FileCount = %d, want 4", got) + if got := withTok.FileCount(); got != 3 { // tokenizer.json ignored + t.Errorf("FileCount with stray tokenizer.json = %d, want 3 (must be ignored)", got) } } diff --git a/internal/push/walk.go b/internal/push/walk.go index 98017617..f9a2f3e4 100644 --- a/internal/push/walk.go +++ b/internal/push/walk.go @@ -59,28 +59,20 @@ type LocalLayout struct { // classification (which uses Images) and tabular (no sidecars). Sidecars map[string][]string - // ExtraFiles maps a staged destination filename to its absolute - // source path, for single root-level files beyond labels.csv — - // e.g. masked_language_modeling's tokenizer.json, which the - // ingestor reads from SRC_PATH/tokenizer.json. Staged verbatim at - // the table root. - ExtraFiles map[string]string - // TotalBytes is the sum of all files Discover will stage — - // labels.csv plus every entry in Images / Sidecars / ExtraFiles. + // labels.csv plus every entry in Images / Sidecars. // Pre-computed during the walk so the size-cap check + the // progress bar can read it without re-stat'ing. TotalBytes int64 } // FileCount returns the total number of files this layout stages: -// labels.csv, every ExtraFile, and every Images / Sidecars entry. Used -// for the "staging N files" messaging so it's accurate across all -// category families. +// labels.csv, and every Images / Sidecars entry. Used for the +// "staging N files" messaging so it's accurate across all category +// families. func (l *LocalLayout) FileCount() int { n := 1 // labels.csv n += len(l.Images) - n += len(l.ExtraFiles) for _, files := range l.Sidecars { n += len(files) } From 404c4415678c693291c750dd4f2b5e6a1443e8e4 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:48:22 +0200 Subject: [PATCH 04/22] feat(data ingest): data-first flow inversion + family-scoped task picker (#180b) (#198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(data ingest): data-first flow inversion + family-scoped task picker (#180b) Invert the guided ingest flow to data-first (RFC-0002 §12.1) and rework task selection so the user is never shown the flat 15-task wall. - Prompt order is now intent → name → path → task → task-specific → review. Name is asked with an example in the hint (not auto-filled). - After the path, the family is sniffed from the layout and echoed ("Found a CSV table — this is tabular data."). The sniff is a HINT, not a lock (§5.1): an ambiguous layout asks the family plainly, an explicit --task skips the sniff entirely, and non-interactive runs never sniff. SniffFamily mirrors the Discover* layout markers — it validates nothing, so it can't fork or replace the real walk. - Family-scoped task picker (§7): only that family's tasks are offered, rendered "Display — one-liner · task_id", split into Available now vs a greyed "Not yet in the CLI" (with the reason). The registry Label field is finally wired up, plus the locked glosses (time_to_event_prediction → "Survival analysis", masked_language_modeling → "fill-mask", seq2seq → "translation / summarization") and a per-task blurb. - Label question (§8) now SELECTs over the real CSV header row (exact match kills the data-ingestors#340 case-mismatch silent-null-label class), worded per task (class vs value-to-predict), skipped for self-supervised text; falls back to free text if the header can't be read. --number-of-keypoints / --time-column unchanged. - Promptable overwrite (folded): a pre-existing destination table is a y/N prompt in interactive mode (yes → replace, no → clean exit 0), still a hard exit 6 non-interactively; a reused --idempotency-key falls through to exit 6 to preserve the data-loss guard. - Copy polish (folded from #179): data/ingest help says "workspace" and "ingest", not "client" and "stage". Aliases unchanged. Exit codes / classify / reclaim matrix and --output-json keys unchanged. Closes #180 Co-Authored-By: Claude Opus 4.8 * fix(data ingest): keep Kubernetes out of the happy-path copy (#180b) The ingest command's Long help described the normal successful flow in Kubernetes terms ("submits an ingestion run to jobs-manager, and watches the ingestor Job") - the exact ceremony RFC-0002 section 6 keeps off the happy path. Reword it to plain language plus the on-prem reassurance. The exit-code section and the --detach reconnect hint still name jobs-manager/kubectl: those are failure/detach diagnostics, which section 6 explicitly permits. Also fix the exit-6 duplicate-table error to say "in this workspace", not "in this client" - matching the workspace wording used everywhere else in the flow. Copy-only; no behavior change. Tests + gofmt + vet green. Co-Authored-By: Claude Opus 4.8 * fix(data ingest): make the family sniff mirror the discovery walk (#180b) SniffFamily + previewLabelCSVPath were an over-permissive fork of the real discovery walk. Align them to what Discover / DiscoverText / DiscoverTabular actually accept so a confident sniff can never promise a layout the walk refuses: - Match the marker dirs (images/, texts/, sequences/) and labels.csv with the literal, case-sensitive names the walk joins + Lstats. A mis-cased "Images/" is no longer read as confident image. Only the .csv extension match stays case-insensitive (mirrors DiscoverTabular's EqualFold). - Require BOTH labels.csv AND the subdir before claiming confident image / text (mirror Discover / DiscoverText); the echo now only names files that were actually found. - Require a directory for tabular: a bare .csv file is no longer confident (DiscoverTabular rejects bare files; that's cli#181). - previewLabelCSVPath now reuses DiscoverTabular's single-CSV rule via a shared findSingleCSV helper, so a multi-CSV directory errors (caller falls back to free text) instead of silently reading the alphabetical first. Interactive flow: - Path prompt copy is folder-oriented ("the folder holding it") — the walk can't take a bare file yet, so don't promise one. - Path prompt gets a validator that rejects an empty/whitespace answer, so a bare Enter can't sniff (and ingest) the current working directory. - Drop the dead famPrompted store in resolveFamily (pickTask sets prompted unconditionally right after). Also removes the unused FamilyOf accessor. Tests pin: mis-cased dir not confident, missing labels.csv not confident image/text, bare .csv not confident tabular, empty path rejected, multi-CSV matches the walk. Co-Authored-By: Claude Opus 4.8 * fix(data ingest): sniff confident tabular only on exactly one CSV (#180b) The family sniff claimed confident tabular whenever a directory held at least one CSV, but DiscoverTabular's findSingleCSV requires exactly one — so a two-CSV directory got echoed "this is tabular data" and then the walk rejected it. That made the sniff more permissive than the walk it mirrors, breaking the sniff's own contract ("never claims more than the matching Discover* would accept"). Gate the tabular case on csvCount == 1 so sniff-confident-tabular holds only for directories the walk would accept structurally; multi-CSV directories now fall through to the plain family question. Adds a regression test asserting both the ambiguous sniff and the walk rejection on the same input. Co-Authored-By: Claude Opus 4.8 * refactor(cli): single source of truth for the Family<->noun mapping (#200) The Family<->noun mapping had three hand-maintained copies that had to stay in sync by hand: push.FamilyNoun (Family->noun), interactive.go's familyFromNoun (noun->Family), and resolveFamily's literal []string{"tabular","image","text"} picker options + "tabular" default. Consolidate to one ordered familyNounTable in internal/push. FamilyNoun, the new exported FamilyFromNoun, and FamilyNouns() (picker options + default) all derive from it. resolveFamily now takes its Select options and default from push.FamilyNouns() and its reverse lookup from push.FamilyFromNoun; the local familyFromNoun is gone. Behavior identical: same order (tabular, image, text), same default (tabular). build/vet/tests green. Co-authored-by: Claude Opus 4.8 * fix(data ingest): #180b review — path trim, self-supervised registry flag, honest text sniff echo (#201) - Trim the interactive path answer before storing it. validateDatasetPath only trims to check for emptiness, so a pasted " ~/data" (stray space) otherwise survived, defeated expandHome (first char isn't '~'), and made filepath.Abs prepend cwd — the family sniff / label-header preview then read a path that doesn't exist (and silently fell back to free-text label entry, the data-ingestors#340 class this feature exists to prevent). - Make SelfSupervised a CategorySpec registry field (set on MLM + CLM) and have SelfSupervisedText read it, so a new self-supervised task can't be added without deciding whether it needs a label column — was a hardcoded two-id switch decoupled from the registry. - Soften the confident text sniff echo to "looks like": texts/ and sequences/ map to DIFFERENT tasks (TextSidecarDir), so a family-level echo must not imply the task the user then picks will load. The walk stays the authoritative check. - Drop the unreachable empty-slice branch in defaultLabelChoice (its only caller already guards len(headers) > 0). Co-authored-by: Claude Opus 4.8 * fix(data ingest): don't sniff a mis-cased media folder + labels.csv as confident tabular (#203) (#204) A directory with labels.csv plus a mis-cased media folder (Images/, Texts/, Sequences/) fell through SniffFamily's confident-tabular branch: the subdir switch is case-sensitive so hasImages/hasText stayed false, labels.csv counted as the one CSV, and the flow confidently echoed "tabular", skipped the family question, and offered only tabular tasks. DiscoverTabular then ignores the subdir, reads labels.csv as the single CSV, and SUCCEEDS — silently ingesting an image/text dataset as a standalone table with the media files dropped and no error. Detect a subdir whose name matches a marker (images/texts/sequences) case-insensitively but not exactly, and bail to ambiguous so the flow asks the family plainly. Narrow: an unrelated subdir (backup/, raw/) still sniffs confident tabular, matching DiscoverTabular which ignores it. The confident image/text branches still require an EXACT match, mirroring the walk's literal os.Lstat. Strengthened the two mis-cased tests to assert the sniff is not confident AT ALL (they previously only checked "not confident image/text" and so missed the tabular masquerade), plus a case pinning that an unrelated subdir stays confident tabular. Co-authored-by: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Asad Iqbal (Saadi) --- internal/cli/data.go | 69 ++++- internal/cli/data_test.go | 67 ++++- internal/cli/interactive.go | 234 ++++++++++++---- internal/cli/interactive_test.go | 460 +++++++++++++++++++++++++------ internal/push/category.go | 142 +++++++++- internal/push/preview.go | 213 ++++++++++++++ internal/push/preview_test.go | 271 ++++++++++++++++++ internal/push/tabular.go | 62 +++-- 8 files changed, 1341 insertions(+), 177 deletions(-) create mode 100644 internal/push/preview.go create mode 100644 internal/push/preview_test.go diff --git a/internal/cli/data.go b/internal/cli/data.go index b0b1a37b..89bdf348 100644 --- a/internal/cli/data.go +++ b/internal/cli/data.go @@ -35,11 +35,11 @@ func newDataCmd() *cobra.Command { cmd := &cobra.Command{ Use: "data", Aliases: []string{"dataset"}, - Short: "Manage the datasets in your client", - Long: `Commands for staging and managing the datasets your client holds — + Short: "Manage the datasets in your workspace", + Long: `Commands for ingesting and managing the datasets your workspace holds — the data models train on. It stays on your infrastructure. -` + "`data ingest`" + ` stages a local dataset into your client's storage, +` + "`data ingest`" + ` ingests a local dataset into your workspace's storage, submits the ingestion run, and watches it to completion (streaming logs + the final summary). ` + "`data validate`" + ` checks an ingest.yaml locally first. @@ -135,10 +135,11 @@ func newDataIngestCmd() *cobra.Command { cmd := &cobra.Command{ Use: "ingest ", Aliases: []string{"push"}, - Short: "Stage a local dataset into your client's storage", - Long: `Stages a local dataset into your client's shared storage, -submits an ingestion run to jobs-manager, and watches the ingestor Job -to completion. Supports 9 tasks (image classification, + Short: "Ingest a local dataset into your workspace", + Long: `Ingests a local dataset into your workspace's storage, +submits the ingestion run, and follows it to completion (streaming +progress + the final summary). Your data never leaves your own +infrastructure. Supports 9 tasks (image classification, object/keypoint detection, text classification, masked language modeling, and the tabular / time-series family); pick one with --task. @@ -724,14 +725,22 @@ collaborators can train against that table without ever seeing the raw files.`)) } tableExists := existingTable != "" if tableExists && !a.Overwrite { - return &exitError{code: 6, err: fmt.Errorf( - "table %q already exists in this client. Re-ingesting the same table doesn't merge or replace — "+ - "the run would fail after uploading everything. Re-run with --overwrite to replace it, "+ - "or pick a different --name. (`tracebloc data delete %s` also removes it.)", - existingTable, existingTable)} + // Folded decision (RFC-0002): in interactive mode a pre-existing table + // is a question, not a wall. Prompt to replace it; a "no" cancels + // cleanly (exit 0). Non-interactive (or --output-json / --no-input) + // still hard-fails exit 6 — a script must opt in with --overwrite. + proceed, aerr := existingTableAction(&a, existingTable) + if aerr != nil { + return aerr + } + if !proceed { + a.Printer.Infof("Cancelled — %q was left as-is; nothing was ingested.", existingTable) + return nil + } + a.Overwrite = true } if tableExists && a.Overwrite { - a.Printer.Warnf("Table %q already exists — --overwrite replaces it (table + files).", existingTable) + a.Printer.Warnf("Table %q already exists — replacing it (table + files).", existingTable) } // 8. Dry-run stop. Acknowledged success, plus a reminder of the @@ -1216,6 +1225,40 @@ func destTableExists(ctx context.Context, cs kubernetes.Interface, resolved *clu return "", "" } +// existingTableAction resolves what to do when the destination table +// already exists and --overwrite was NOT passed on the command line. +// +// - proceed=true, err=nil → replace it: the caller sets Overwrite and +// runs the same teardown `data delete` does. +// - proceed=false, err=nil → the user declined the replace prompt; a +// clean cancel (exit 0), nothing ingested. +// - err != nil (exit 6) → non-interactive and no --overwrite: refuse, +// same hard contract scripts have always had. +// +// Interactive mode prompts to replace UNLESS a --idempotency-key was +// reused: a reused key + a replace is the data-loss trap the top-of-func +// guard forbids (the teardown removes the data, then the cluster replays +// the old run and ingests nothing), so that combination falls through to +// the exit-6 refusal rather than being offered as a prompt. +func existingTableAction(a *runDataIngestArgs, existingTable string) (proceed bool, err error) { + if a.Interactive && a.Prompter != nil && a.IdempotencyKey == "" { + ok, perr := a.Prompter.Confirm(fmt.Sprintf( + "A dataset named %q already exists — replace it?", existingTable), false) + if perr != nil { + if errors.Is(perr, errInteractiveCancelled) { + return false, nil + } + return false, &exitError{code: 3, err: fmt.Errorf("overwrite prompt: %w", perr)} + } + return ok, nil + } + return false, &exitError{code: 6, err: fmt.Errorf( + "table %q already exists in this workspace. Re-ingesting the same table doesn't merge or replace — "+ + "the run would fail after uploading everything. Re-run with --overwrite to replace it, "+ + "or pick a different --name. (`tracebloc data delete %s` also removes it.)", + existingTable, existingTable)} +} + // 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 diff --git a/internal/cli/data_test.go b/internal/cli/data_test.go index 0796307b..2dfad932 100644 --- a/internal/cli/data_test.go +++ b/internal/cli/data_test.go @@ -379,12 +379,12 @@ func TestAliasResolution(t *testing.T) { { name: "dataset push alias resolves", args: []string{"dataset", "push", "--help"}, - want: "Stages a local dataset", + want: "Ingests a local dataset", }, { name: "data ingest canonical", args: []string{"data", "ingest", "--help"}, - want: "Stages a local dataset", + want: "Ingests a local dataset", }, { name: "dataset rm alias resolves", @@ -462,6 +462,69 @@ func TestDestTableExists(t *testing.T) { } } +// existingTableAction is the folded promptable-overwrite decision: a +// pre-existing table hard-fails exit 6 non-interactively, but becomes a +// y/N prompt on a terminal (yes → replace, no → clean cancel). +func TestExistingTableAction(t *testing.T) { + t.Run("non-interactive refuses with exit 6", func(t *testing.T) { + a := &runDataIngestArgs{Interactive: false, Printer: ui.New(&bytes.Buffer{})} + proceed, err := existingTableAction(a, "churn") + if proceed { + t.Error("non-interactive must not proceed without --overwrite") + } + if ExitCodeFromError(err) != 6 { + t.Errorf("exit code = %d, want 6", ExitCodeFromError(err)) + } + }) + + t.Run("interactive yes → replace", func(t *testing.T) { + yes := true + a := &runDataIngestArgs{ + Interactive: true, + Prompter: &fakePrompter{confirm: &yes}, + Printer: ui.New(&bytes.Buffer{}), + } + proceed, err := existingTableAction(a, "churn") + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !proceed { + t.Error("a yes to the replace prompt must proceed (overwrite)") + } + }) + + t.Run("interactive no → clean cancel", func(t *testing.T) { + no := false + a := &runDataIngestArgs{ + Interactive: true, + Prompter: &fakePrompter{confirm: &no}, + Printer: ui.New(&bytes.Buffer{}), + } + proceed, err := existingTableAction(a, "churn") + if err != nil { + t.Fatalf("a declined prompt is a clean cancel, not an error: %v", err) + } + if proceed { + t.Error("a no to the replace prompt must not proceed") + } + }) + + t.Run("reused idempotency key falls through to exit 6", func(t *testing.T) { + yes := true + a := &runDataIngestArgs{ + Interactive: true, + Prompter: &fakePrompter{confirm: &yes}, + IdempotencyKey: "abc", + Printer: ui.New(&bytes.Buffer{}), + } + proceed, err := existingTableAction(a, "churn") + if proceed || ExitCodeFromError(err) != 6 { + t.Errorf("a reused --idempotency-key must not be offered a replace prompt: proceed=%v code=%d", + proceed, ExitCodeFromError(err)) + } + }) +} + // The images summary line surfaces the detected extension — the visible // half of the cli#68 fix (the spec half is pinned in internal/push). func TestPrintLocalSummary_ShowsDetectedExtension(t *testing.T) { diff --git a/internal/cli/interactive.go b/internal/cli/interactive.go index 3b561008..5f28b277 100644 --- a/internal/cli/interactive.go +++ b/internal/cli/interactive.go @@ -15,14 +15,6 @@ import ( "github.com/tracebloc/cli/internal/ui" ) -// promptCategories is the ordered list offered by the interactive -// category picker. It derives from the push registry's CLI-supported -// set — the exact categories runDataIngest's gate accepts — so the -// picker can't drift from what `data ingest` actually supports. -// semantic_segmentation is excluded (CLISupported=false) until it's -// implemented. -var promptCategories = push.SupportedCategoryIDs() - // prompter is the narrow seam over the interactive library. Production // uses surveyPrompter (a real terminal); tests inject a fake that // returns scripted answers, so the prompt-mapping logic is unit- @@ -103,42 +95,36 @@ func isInteractiveTTY() bool { } // runInteractive fills the gaps in a's core ingest fields by prompting, -// then returns. It only prompts for what's still missing, so flags the -// user already passed win. taskSet says whether the task was passed +// data-first (RFC-0002 §12.1): intent → name → path → task → task-specific +// questions → review. It only prompts for what's still missing, so flags +// the user already passed win. taskSet says whether the task was passed // explicitly (via --task or the hidden --category alias); when it wasn't, -// the picker runs rather than assuming a default. +// the family is sniffed from the data the user pointed at (echoed back, or +// asked plainly when ambiguous) and only that family's tasks are offered. // -// Mutates a through the pointer. PR-b adds task-specific prompts -// (target-size, schema, number-of-keypoints) + a confirm screen. +// Mutates a through the pointer. func runInteractive(p *ui.Printer, pr prompter, a *runDataIngestArgs, taskSet bool) error { p.PromptHeader("Let's set up your data ingest") p.Hintf("Press Enter to accept a default; Ctrl-C to cancel.") prompted := false - if a.LocalPath == "" { - p.PromptHint("The folder holding your dataset — a single .csv for tabular, or labels.csv + an images/ folder for images. e.g. ~/datasets/churn") - ans, err := pr.Input("Path to your dataset directory", "e.g. ./my-data", "", nil) - if err != nil { - return err - } - a.LocalPath = ans - prompted = true - } - - if !taskSet { - p.PromptHint("What kind of task your data is for — this drives how it's validated and loaded.") - ans, err := pr.Select("Task", "what kind of data this is", - promptCategories, a.Spec.Category) + // (a) intent — the first thing to settle: what this data is for. + if a.Spec.Intent == "" { + p.PromptHint("Whether this split trains the model or evaluates it.") + ans, err := pr.Select("Is this training or test data?", "which split this data is", + []string{"train", "test"}, "train") if err != nil { return err } - a.Spec.Category = ans + a.Spec.Intent = ans prompted = true } + // (b) name — no auto-fill: the example lives in the hint, so the user + // types their own name rather than editing a pre-filled default. if a.Spec.Table == "" { - p.PromptHint("Names the table created on the cluster (and its folder on the shared storage). Letters, digits, underscores only. e.g. churn_train") - ans, err := pr.Input("Destination table name", + p.PromptHint("A name for this dataset — you'll reference it by this name when you start a training run. Letters, digits, underscores. e.g. churn_train") + ans, err := pr.Input("What should we call this dataset?", "MySQL identifier + PVC subdir; letters, digits, underscore only", "", push.ValidateTableName) if err != nil { @@ -148,37 +134,51 @@ func runInteractive(p *ui.Printer, pr prompter, a *runDataIngestArgs, taskSet bo prompted = true } - if a.Spec.Intent == "" { - p.PromptHint("Whether this split is used to train the model or to evaluate it.") - ans, err := pr.Select("Is this training or test data?", "which split this data is", - []string{"train", "test"}, "train") + // (c) path — then detect the family from the layout and echo it back. + if a.LocalPath == "" { + p.PromptHint("The folder holding your data — a single .csv for a table, or labels.csv + an images/ folder for images. e.g. ~/datasets/churn") + ans, err := pr.Input("Where is your data? (the folder holding it)", "e.g. ./my-data", "", validateDatasetPath) if err != nil { return err } - a.Spec.Intent = ans + // Trim before storing: validateDatasetPath only trims to check for + // emptiness, so a pasted " ~/data" (stray leading/trailing space) + // would otherwise survive here and defeat expandHome (first char + // isn't '~') — filepath.Abs then prepends cwd and the sniff / label + // preview read a path that doesn't exist. + a.LocalPath = strings.TrimSpace(ans) prompted = true } + // Expand a leading ~ now so the family sniff + label-header preview read + // the real path; runDataIngest's own expandHome then no-ops. + a.LocalPath = expandHome(a.LocalPath) - // masked_language_modeling is self-supervised — no label column. - if a.Spec.LabelColumn == "" && a.Spec.Category != "masked_language_modeling" { - p.PromptHint("The column in your CSV holding the value to predict (the target). e.g. label, target, churned") - ans, err := pr.Input("Label column", - "the column in labels.csv that holds the label", "label", nil) + // (d) task — family-scoped. An explicit --task wins and skips both the + // sniff and the picker (§5.1). Otherwise the family is sniffed from the + // layout (and echoed), or asked plainly when the layout is ambiguous, + // and then only that family's tasks are offered. + if !taskSet { + fam, err := resolveFamily(p, pr, a.LocalPath) if err != nil { return err } - a.Spec.LabelColumn = ans + id, err := pickTask(p, pr, fam) + if err != nil { + return err + } + a.Spec.Category = id prompted = true } + // (e) task-specific questions, including the label column. cp, err := promptCategorySpecific(p, pr, a) if err != nil { return err } prompted = prompted || cp - // Confirm only when we actually prompted something — an ingest that's - // fully specified by flags (on a TTY) isn't nagged with a confirm. + // (f) review + single confirm. Only when we actually prompted something + // — an ingest fully specified by flags (on a TTY) isn't nagged. if prompted { renderReview(p, a) ok, err := pr.Confirm("Proceed with the ingest?", true) @@ -192,12 +192,107 @@ func runInteractive(p *ui.Printer, pr prompter, a *runDataIngestArgs, taskSet bo return nil } -// promptCategorySpecific prompts for the inputs a particular category -// needs beyond the core fields, filling only the gaps. Returns whether -// it prompted anything (so the caller knows to show the confirm). +// resolveFamily turns the data the user pointed at into a task family. The +// sniff is a HINT, not a lock (§5.1): a confident layout is echoed and +// used; an ambiguous one is asked plainly. (The caller unconditionally +// prompts for the task afterward, so resolveFamily needn't report whether +// it prompted the family question.) +func resolveFamily(p *ui.Printer, pr prompter, path string) (push.Family, error) { + if s := push.SniffFamily(path); s.Confident { + p.Successf("%s", s.Echo) + return s.Family, nil + } + p.PromptHint("We couldn't tell the data type from what's there — which is it?") + opts := push.FamilyNouns() + ans, err := pr.Select("What kind of data is this?", + "tabular = a CSV table; image = labels.csv + images/; text = labels.csv + texts/", + opts, opts[0]) + if err != nil { + return 0, err + } + return push.FamilyFromNoun(ans), nil +} + +// pickTask renders the family's tasks — "Display name — one-liner · +// task_id", split into Available now and (greyed) Not yet in the CLI — +// and asks the user to pick one of the available ones. It never shows the +// flat 15-item wall: only this family's tasks appear (§7). +func pickTask(p *ui.Printer, pr prompter, fam push.Family) (string, error) { + var available, pending []push.CategorySpec + for _, s := range push.CategoriesByFamily(fam) { + if s.CLISupported { + available = append(available, s) + } else { + pending = append(pending, s) + } + } + if len(available) == 0 { + // Can't happen with today's registry (every family has a supported + // task); guard so a future all-pending family fails loudly, not with + // an index panic. + return "", fmt.Errorf("no CLI-supported tasks for %s data yet", push.FamilyNoun(fam)) + } + + p.Section(fmt.Sprintf("Tasks for %s data", push.FamilyNoun(fam))) + p.Hintf("Available now:") + for _, s := range available { + p.Infof("%s — %s · %s", s.DisplayName(), s.Blurb, s.ID) + } + if len(pending) > 0 { + p.Hintf("Not yet in the CLI:") + for _, s := range pending { + p.Hintf(" %s — %s · %s (%s)", s.DisplayName(), s.Blurb, s.ID, s.UnsupportedNote) + } + } + + opts := make([]string, len(available)) + byName := make(map[string]string, len(available)) + for i, s := range available { + opts[i] = s.DisplayName() + byName[s.DisplayName()] = s.ID + } + ans, err := pr.Select("Which task?", "pick the task this data is for", opts, opts[0]) + if err != nil { + return "", err + } + if id, ok := byName[ans]; ok { + return id, nil + } + // Defensive: an answer that isn't one of the offered display names. + // Never return an empty category — fall back to the first available. + return available[0].ID, nil +} + +// promptCategorySpecific prompts for the inputs a particular task needs +// beyond the core fields, filling only the gaps. The label column comes +// first (it's the one question every non-self-supervised task shares), +// then the family-specific extras. Returns whether it prompted anything +// (so the caller knows to show the confirm). func promptCategorySpecific(p *ui.Printer, pr prompter, a *runDataIngestArgs) (bool, error) { cat := a.Spec.Category prompted := false + + // Label column — the answer the model learns to produce. Skipped for + // self-supervised text (MLM/CLM: the target comes from the text itself, + // there's no label column). Interactive picks from the REAL CSV header + // row so the choice exact-matches a column that exists — killing the + // case-mismatch silent-null-label class (data-ingestors#340) that + // free-typing "Label" against a "label" header would cause. Wording is + // per-task: a class to sort into vs a numeric value to predict (§8). + if !push.SelfSupervisedText(cat) && a.Spec.LabelColumn == "" { + question := "Which column holds the class?" + if push.IsRegressionClass(cat) { + question = "Which column holds the value to predict?" + } + p.PromptHint("The column in your CSV with the answer the model learns to produce.") + ans, err := promptLabelColumn(pr, cat, a.LocalPath, question) + if err != nil { + return prompted, err + } + a.Spec.LabelColumn = ans + prompted = true + } + switch { case push.IsImage(cat): if cat == "keypoint_detection" && a.Spec.NumberOfKeypoints <= 0 { @@ -257,14 +352,44 @@ func promptCategorySpecific(p *ui.Printer, pr prompter, a *runDataIngestArgs) (b return prompted, nil } +// promptLabelColumn asks for the label/target column. When the CSV header +// can be read, it offers those columns as an exact-match SELECT (defaulting +// to a column literally named "label" if present); otherwise — the header +// isn't readable yet — it falls back to free text so the flow never stalls. +func promptLabelColumn(pr prompter, category, root, question string) (string, error) { + headers, err := push.PreviewLabelHeaders(category, root) + if err == nil && len(headers) > 0 { + ans, serr := pr.Select(question, + "pick the label/target column from your CSV header", headers, defaultLabelChoice(headers)) + return strings.TrimSpace(ans), serr + } + ans, ierr := pr.Input(question, "the label/target column name", "", nil) + return strings.TrimSpace(ans), ierr +} + +// defaultLabelChoice pre-highlights a column literally named "label" +// (case-insensitive) when one exists, else the first column — a sensible +// starting point for the SELECT. +func defaultLabelChoice(headers []string) string { + for _, h := range headers { + if strings.EqualFold(h, "label") { + return h + } + } + // The only caller (promptLabelColumn) guards len(headers) > 0 before + // calling, so headers is never empty here. + return headers[0] +} + // renderReview prints the assembled ingest inputs before the confirm -// prompt, so the user sees exactly what's about to happen. +// prompt, so the user sees exactly what's about to happen. Order mirrors +// the data-first flow: name → task → intent → path, then task extras. func renderReview(p *ui.Printer, a *runDataIngestArgs) { p.Section("Review") - p.Field("path", a.LocalPath) - p.Field("task", a.Spec.Category) p.Field("name", a.Spec.Table) + p.Field("task", a.Spec.Category) p.Field("intent", a.Spec.Intent) + p.Field("path", a.LocalPath) if a.Spec.LabelColumn != "" { p.Field("label column", a.Spec.LabelColumn) } @@ -291,6 +416,17 @@ func renderReview(p *ui.Printer, a *runDataIngestArgs) { } } +// validateDatasetPath rejects an empty / whitespace-only answer. Without +// it, a bare Enter at the path prompt yields "" — and SniffFamily(Abs("")) +// would sniff the current working directory before any empty-path guard +// runs, silently ingesting whatever happens to sit in the cwd. +func validateDatasetPath(s string) error { + if strings.TrimSpace(s) == "" { + return fmt.Errorf("a dataset path is required") + } + return nil +} + // validatePositiveInt accepts a string that parses to an int > 0. func validatePositiveInt(s string) error { if n, err := strconv.Atoi(strings.TrimSpace(s)); err != nil || n <= 0 { diff --git a/internal/cli/interactive_test.go b/internal/cli/interactive_test.go index 18b24d40..529ed740 100644 --- a/internal/cli/interactive_test.go +++ b/internal/cli/interactive_test.go @@ -3,6 +3,8 @@ package cli import ( "bytes" "errors" + "os" + "path/filepath" "strings" "testing" @@ -12,12 +14,13 @@ import ( // fakePrompter is the test double for the prompter seam: it returns // scripted answers keyed by prompt label and records the order of -// labels asked, so tests can assert WHICH fields were prompted and how -// answers map onto SpecArgs — with no real terminal involved. +// labels asked, so tests can assert WHICH fields were prompted (and in +// what order) and how answers map onto SpecArgs — with no real terminal +// involved. type fakePrompter struct { answers map[string]string asked []string - confirm *bool // nil → return the prompt's default (true) + confirm *bool // nil → return the prompt's default } func (f *fakePrompter) answer(label, def string) string { @@ -51,75 +54,313 @@ func (f *fakePrompter) Confirm(_ string, def bool) (bool, error) { func discardPrinter() *ui.Printer { return ui.New(&bytes.Buffer{}) } -// TestRunInteractive_FillsAllWhenEmpty: a bare invocation prompts for -// every core field and maps the answers onto SpecArgs. -func TestRunInteractive_FillsAllWhenEmpty(t *testing.T) { +// tabularDir drops a directory holding a single CSV with a known header, +// so the family sniff reads "tabular" and the label picker can offer real +// columns. +func tabularDir(t *testing.T) string { + t.Helper() + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "data.csv"), + []byte("age,income,churned\n42,50000,yes\n"), 0o644); err != nil { + t.Fatalf("write data.csv: %v", err) + } + return root +} + +// imageDirLayout drops labels.csv + an images/ folder so the sniff reads +// "image". +func imageDirLayout(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 { + t.Fatalf("write labels.csv: %v", err) + } + if err := os.MkdirAll(filepath.Join(root, "images"), 0o755); err != nil { + t.Fatalf("mkdir images: %v", err) + } + return root +} + +// textDirLayout drops labels.csv + a texts/ folder so the sniff reads +// "text". +func textDirLayout(t *testing.T) string { + t.Helper() + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "labels.csv"), + []byte("text_id,label\n001.txt,spam\n"), 0o644); err != nil { + t.Fatalf("write labels.csv: %v", err) + } + if err := os.MkdirAll(filepath.Join(root, "texts"), 0o755); err != nil { + t.Fatalf("mkdir texts: %v", err) + } + return root +} + +// TestRunInteractive_PromptOrder: a bare invocation prompts data-first — +// intent, then name, then path, then task — before any task-specific +// question. Pins the RFC-0002 §12.1 order. +func TestRunInteractive_PromptOrder(t *testing.T) { + dir := tabularDir(t) f := &fakePrompter{answers: map[string]string{ - "Path to your dataset directory": "./data", - "Task": "tabular_classification", - "Destination table name": "churn_train", - "Is this training or test data?": "test", - "Label column": "churned", + "Is this training or test data?": "test", + "What should we call this dataset?": "churn_train", + "Where is your data? (the folder holding it)": dir, + "Which task?": "Tabular classification", + "Which column holds the class?": "churned", }} - a := &runDataIngestArgs{Spec: push.SpecArgs{Category: "image_classification"}} + a := &runDataIngestArgs{} + if err := runInteractive(discardPrinter(), f, a, false /*taskSet*/); err != nil { + t.Fatalf("runInteractive: %v", err) + } - if err := runInteractive(discardPrinter(), f, a, false); err != nil { + // The four core questions must appear in data-first order, ahead of the + // label question. + want := []string{ + "Is this training or test data?", + "What should we call this dataset?", + "Where is your data? (the folder holding it)", + "Which task?", + "Which column holds the class?", + } + if !orderedSubsequence(f.asked, want) { + t.Errorf("prompt order = %v, want subsequence %v", f.asked, want) + } + if a.Spec.Intent != "test" || a.Spec.Table != "churn_train" || + a.LocalPath != dir || a.Spec.Category != "tabular_classification" || + a.Spec.LabelColumn != "churned" { + t.Errorf("fields not mapped: %+v localPath=%q", a.Spec, a.LocalPath) + } +} + +// TestRunInteractive_SniffEchoesFamily: a confident layout is echoed back +// and the family question is NOT asked (the sniff is enough). +func TestRunInteractive_SniffEchoesFamily(t *testing.T) { + dir := tabularDir(t) + f := &fakePrompter{answers: map[string]string{ + "What should we call this dataset?": "t", + "Which column holds the class?": "churned", + }} + a := &runDataIngestArgs{LocalPath: dir, Spec: push.SpecArgs{Intent: "train"}} + var buf bytes.Buffer + p := ui.New(&buf, ui.WithColor(false)) + if err := runInteractive(p, f, a, false); err != nil { t.Fatalf("runInteractive: %v", err) } - if a.LocalPath != "./data" { - t.Errorf("LocalPath = %q, want ./data", a.LocalPath) + if !strings.Contains(buf.String(), "Found a CSV table") { + t.Errorf("expected a tabular sniff echo, got:\n%s", buf.String()) + } + for _, l := range f.asked { + if l == "What kind of data is this?" { + t.Errorf("a confident sniff must not ask the family question") + } } if a.Spec.Category != "tabular_classification" { t.Errorf("Category = %q, want tabular_classification", a.Spec.Category) } - if a.Spec.Table != "churn_train" { - t.Errorf("Table = %q, want churn_train", a.Spec.Table) +} + +// TestRunInteractive_SniffIsHintNotLock: an ambiguous layout falls back to +// asking the family plainly, then scopes the picker to the answer. +func TestRunInteractive_SniffIsHintNotLock(t *testing.T) { + empty := t.TempDir() // no csv, no images/, no texts/ → ambiguous + f := &fakePrompter{answers: map[string]string{ + "What should we call this dataset?": "t", + "What kind of data is this?": "image", + "Which task?": "Image classification", + "Which column holds the class?": "label", + }} + a := &runDataIngestArgs{LocalPath: empty, Spec: push.SpecArgs{Intent: "train"}} + if err := runInteractive(discardPrinter(), f, a, false); err != nil { + t.Fatalf("runInteractive: %v", err) } - if a.Spec.Intent != "test" { - t.Errorf("Intent = %q, want test", a.Spec.Intent) + if !contains(f.asked, "What kind of data is this?") { + t.Errorf("ambiguous layout should ask the family plainly; asked=%v", f.asked) } - if a.Spec.LabelColumn != "churned" { - t.Errorf("LabelColumn = %q, want churned", a.Spec.LabelColumn) + if a.Spec.Category != "image_classification" { + t.Errorf("Category = %q, want image_classification (family answer honored)", a.Spec.Category) } } -// TestRunInteractive_ShowsExampleHints: each input prompt is preceded -// by a visible hint with an example, so the guided flow teaches as it -// goes. Drives runInteractive with a real (buffer-backed) Printer and -// asserts the example text lands in the output. -func TestRunInteractive_ShowsExampleHints(t *testing.T) { +// TestRunInteractive_ExplicitTaskSkipsSniff: an explicit --task wins — no +// sniff echo, no family question, no task picker. +func TestRunInteractive_ExplicitTaskSkipsSniff(t *testing.T) { + dir := tabularDir(t) f := &fakePrompter{answers: map[string]string{ - "Path to your dataset directory": "./d", - "Destination table name": "churn_train", + "What should we call this dataset?": "t", + "Which column holds the class?": "churned", }} - a := &runDataIngestArgs{Spec: push.SpecArgs{Category: "tabular_regression"}} - + a := &runDataIngestArgs{ + LocalPath: dir, + Spec: push.SpecArgs{Category: "tabular_classification", Intent: "train"}, + } var buf bytes.Buffer p := ui.New(&buf, ui.WithColor(false)) if err := runInteractive(p, f, a, true /*taskSet*/); err != nil { t.Fatalf("runInteractive: %v", err) } + for _, l := range f.asked { + if l == "Which task?" || l == "What kind of data is this?" { + t.Errorf("explicit --task must skip the picker/sniff; asked %q", l) + } + } + if strings.Contains(buf.String(), "Found a CSV table") { + t.Errorf("explicit --task must not echo a sniff") + } +} + +// TestPickTask_FamilyScoped: the picker offers only the given family's +// tasks, wires the friendly display names + the locked glosses, and lists +// the not-yet-supported ones (greyed, with a reason) — never the other +// families' tasks. +func TestPickTask_FamilyScoped(t *testing.T) { + // Text family: fill-mask (gloss) is available; seq2seq + // (translation / summarization, gloss) + token_classification are + // pending; image/tabular tasks must not appear. + f := &fakePrompter{answers: map[string]string{"Which task?": "Text classification"}} + var buf bytes.Buffer + p := ui.New(&buf, ui.WithColor(false)) + id, err := pickTask(p, f, push.FamilyText) + if err != nil { + t.Fatalf("pickTask: %v", err) + } + if id != "text_classification" { + t.Errorf("id = %q, want text_classification", id) + } out := buf.String() for _, want := range []string{ - "e.g. churn_train", // table-name example - "e.g. label, target", // label-column example - "age:INT", // tabular schema example - "keeps raw values", // label-policy explanation + "Tasks for text data", + "fill-mask", // MLM gloss (available) + "Text classification", // label + "Not yet in the CLI:", // pending header + "translation / summarization", // seq2seq gloss (pending) + "token_classification", // pending id + "schema-recognized", // an UnsupportedNote fragment } { if !strings.Contains(out, want) { - t.Errorf("interactive output missing hint %q:\n%s", want, out) + t.Errorf("picker output missing %q:\n%s", want, out) + } + } + // Other families must not leak in. + for _, unwanted := range []string{"Image classification", "Tabular classification", "Survival analysis"} { + if strings.Contains(out, unwanted) { + t.Errorf("text picker leaked a non-text task %q:\n%s", unwanted, out) + } + } +} + +// TestPickTask_TabularGloss: the tabular picker shows the survival-analysis +// gloss for time_to_event_prediction and can select it back to its id. +func TestPickTask_TabularGloss(t *testing.T) { + f := &fakePrompter{answers: map[string]string{"Which task?": "Survival analysis"}} + var buf bytes.Buffer + p := ui.New(&buf, ui.WithColor(false)) + id, err := pickTask(p, f, push.FamilyTabular) + if err != nil { + t.Fatalf("pickTask: %v", err) + } + if id != "time_to_event_prediction" { + t.Errorf("id = %q, want time_to_event_prediction", id) + } + if !strings.Contains(buf.String(), "Survival analysis") { + t.Errorf("tabular picker missing the survival-analysis gloss:\n%s", buf.String()) + } +} + +// TestRunInteractive_LabelSelectFromHeaders: the label question is a SELECT +// over the real CSV header row, so the chosen column exact-matches one that +// exists (killing the case-mismatch silent-null-label bug). +func TestRunInteractive_LabelSelectFromHeaders(t *testing.T) { + dir := tabularDir(t) // header: age,income,churned + // Script an answer that only works if the options were the real headers. + f := &fakePrompter{answers: map[string]string{ + "What should we call this dataset?": "t", + "Which task?": "Tabular classification", + "Which column holds the class?": "income", + }} + a := &runDataIngestArgs{LocalPath: dir, Spec: push.SpecArgs{Intent: "train"}} + if err := runInteractive(discardPrinter(), f, a, false); err != nil { + t.Fatalf("runInteractive: %v", err) + } + if a.Spec.LabelColumn != "income" { + t.Errorf("LabelColumn = %q, want income", a.Spec.LabelColumn) + } +} + +// TestRunInteractive_RegressionLabelWording: a regression-class task words +// the label question as the value to predict, not a class. +func TestRunInteractive_RegressionLabelWording(t *testing.T) { + dir := tabularDir(t) + f := &fakePrompter{answers: map[string]string{ + "Which column holds the value to predict?": "income", + }} + a := &runDataIngestArgs{ + LocalPath: dir, + Spec: push.SpecArgs{Category: "tabular_regression", Table: "t", Intent: "train"}, + } + if err := runInteractive(discardPrinter(), f, a, true /*taskSet*/); err != nil { + t.Fatalf("runInteractive: %v", err) + } + if !contains(f.asked, "Which column holds the value to predict?") { + t.Errorf("regression should ask for the value to predict; asked=%v", f.asked) + } + if contains(f.asked, "Which column holds the class?") { + t.Errorf("regression must not use the class wording") + } + if a.Spec.LabelColumn != "income" { + t.Errorf("LabelColumn = %q, want income", a.Spec.LabelColumn) + } +} + +// TestRunInteractive_LabelFreeTextFallback: when the header can't be read +// (no CSV where the label would live), the label question falls back to +// free text rather than stalling. +func TestRunInteractive_LabelFreeTextFallback(t *testing.T) { + empty := t.TempDir() // no labels.csv → PreviewLabelHeaders errors + f := &fakePrompter{answers: map[string]string{ + "Which column holds the class?": "my_label", + }} + a := &runDataIngestArgs{ + LocalPath: empty, + Spec: push.SpecArgs{Category: "image_classification", Table: "t", Intent: "train"}, + } + if err := runInteractive(discardPrinter(), f, a, true); err != nil { + t.Fatalf("runInteractive: %v", err) + } + if a.Spec.LabelColumn != "my_label" { + t.Errorf("LabelColumn = %q, want my_label (free-text fallback)", a.Spec.LabelColumn) + } +} + +// TestRunInteractive_MLMSkipsLabel: masked_language_modeling is +// self-supervised — the label question must not be asked. +func TestRunInteractive_MLMSkipsLabel(t *testing.T) { + dir := textDirLayout(t) + f := &fakePrompter{answers: map[string]string{ + "What should we call this dataset?": "mlm_train", + "Which task?": "fill-mask", + }} + a := &runDataIngestArgs{LocalPath: dir, Spec: push.SpecArgs{Intent: "train"}} + if err := runInteractive(discardPrinter(), f, a, false); err != nil { + t.Fatalf("runInteractive: %v", err) + } + for _, l := range f.asked { + if strings.HasPrefix(l, "Which column holds") { + t.Errorf("masked_language_modeling should not ask for a label column") } } + if a.Spec.Category != "masked_language_modeling" { + t.Errorf("Category = %q, want masked_language_modeling", a.Spec.Category) + } } // TestRunInteractive_SkipsProvidedValues: flags already set (and an // explicit --task) mean nothing is prompted. func TestRunInteractive_SkipsProvidedValues(t *testing.T) { + dir := textDirLayout(t) f := &fakePrompter{answers: map[string]string{}} - // text_classification has no task-specific prompts, so with all - // core fields set + an explicit --task, nothing is asked. a := &runDataIngestArgs{ - LocalPath: "./data", + LocalPath: dir, Spec: push.SpecArgs{ Category: "text_classification", Table: "t", Intent: "train", LabelColumn: "label", }, @@ -132,13 +373,17 @@ func TestRunInteractive_SkipsProvidedValues(t *testing.T) { } } -// TestRunInteractive_Keypoint prompts for the required keypoint count; -// the optional resolution left blank means auto-detect. +// TestRunInteractive_Keypoint prompts for the required keypoint count; the +// optional resolution left blank means auto-detect. func TestRunInteractive_Keypoint(t *testing.T) { - f := &fakePrompter{answers: map[string]string{"Number of keypoints per sample": "17"}} + dir := imageDirLayout(t) + f := &fakePrompter{answers: map[string]string{ + "Number of keypoints per sample": "17", + "Which column holds the class?": "image_label", + }} a := &runDataIngestArgs{ - LocalPath: "./kp", - Spec: push.SpecArgs{Category: "keypoint_detection", Table: "kp_train", Intent: "train", LabelColumn: "image_label"}, + LocalPath: dir, + Spec: push.SpecArgs{Category: "keypoint_detection", Table: "kp_train", Intent: "train"}, } if err := runInteractive(discardPrinter(), f, a, true); err != nil { t.Fatalf("runInteractive: %v", err) @@ -154,10 +399,14 @@ func TestRunInteractive_Keypoint(t *testing.T) { // TestRunInteractive_TabularRegression prompts for the label policy // (regression-class) and leaves the schema to inference. func TestRunInteractive_TabularRegression(t *testing.T) { - f := &fakePrompter{answers: map[string]string{"Label policy": "passthrough"}} + dir := tabularDir(t) + f := &fakePrompter{answers: map[string]string{ + "Label policy": "passthrough", + "Which column holds the value to predict?": "income", + }} a := &runDataIngestArgs{ - LocalPath: "./tab", - Spec: push.SpecArgs{Category: "tabular_regression", Table: "reg_train", Intent: "train", LabelColumn: "Target"}, + LocalPath: dir, + Spec: push.SpecArgs{Category: "tabular_regression", Table: "reg_train", Intent: "train"}, } if err := runInteractive(discardPrinter(), f, a, true); err != nil { t.Fatalf("runInteractive: %v", err) @@ -173,54 +422,111 @@ func TestRunInteractive_TabularRegression(t *testing.T) { // TestRunInteractive_Cancel: declining the confirm returns the // cancellation sentinel — a clean abort, not a failure. func TestRunInteractive_Cancel(t *testing.T) { + dir := tabularDir(t) no := false f := &fakePrompter{ - answers: map[string]string{"Path to your dataset directory": "./x"}, + answers: map[string]string{ + "What should we call this dataset?": "t", + "Which column holds the class?": "churned", + }, confirm: &no, } - // path is prompted (→ prompted=true → a confirm is shown); the rest - // is pre-set so we reach the confirm cleanly. - a := &runDataIngestArgs{Spec: push.SpecArgs{ - Category: "image_classification", Table: "t", Intent: "train", LabelColumn: "label", - }} - if err := runInteractive(discardPrinter(), f, a, true); !errors.Is(err, errInteractiveCancelled) { + a := &runDataIngestArgs{LocalPath: dir, Spec: push.SpecArgs{Intent: "train"}} + if err := runInteractive(discardPrinter(), f, a, false); !errors.Is(err, errInteractiveCancelled) { t.Fatalf("err = %v, want errInteractiveCancelled", err) } } -// TestRunInteractive_MLMSkipsLabel: masked_language_modeling has no -// label column, so it must not be prompted. -func TestRunInteractive_MLMSkipsLabel(t *testing.T) { +// TestRunInteractive_RejectsBadName: the name prompt runs +// push.ValidateTableName, so an unsafe name surfaces as an error. +func TestRunInteractive_RejectsBadName(t *testing.T) { + f := &fakePrompter{answers: map[string]string{"What should we call this dataset?": "../bad"}} + a := &runDataIngestArgs{Spec: push.SpecArgs{Intent: "train"}} + if err := runInteractive(discardPrinter(), f, a, true); err == nil { + t.Fatal("expected an error for an invalid name, got nil") + } +} + +// TestRunInteractive_RejectsEmptyPath: a bare Enter at the path prompt is +// rejected by the validator rather than sniffing the current working +// directory (empty path → Abs("") → cwd). +func TestRunInteractive_RejectsEmptyPath(t *testing.T) { f := &fakePrompter{answers: map[string]string{ - "Destination table name": "mlm_train", - "Is this training or test data?": "train", + "What should we call this dataset?": "t", + "Where is your data? (the folder holding it)": " ", }} - a := &runDataIngestArgs{ - LocalPath: "./data", - Spec: push.SpecArgs{Category: "masked_language_modeling"}, + a := &runDataIngestArgs{Spec: push.SpecArgs{Intent: "train"}} + if err := runInteractive(discardPrinter(), f, a, false); err == nil { + t.Fatal("expected an error for an empty dataset path, got nil") } - if err := runInteractive(discardPrinter(), f, a, true); err != nil { +} + +// TestRunInteractive_TrimsPath: a path answer with surrounding whitespace +// (a common paste artifact) is trimmed before it's stored, so expandHome +// and the family sniff read the real path rather than a cwd-prefixed +// mangle. Without the trim, " " defeats expandHome and the sniff +// would land in the wrong place. +func TestRunInteractive_TrimsPath(t *testing.T) { + dir := tabularDir(t) + f := &fakePrompter{answers: map[string]string{ + "What should we call this dataset?": "t", + "Where is your data? (the folder holding it)": " " + dir + " ", + "Which column holds the class?": "churned", + }} + a := &runDataIngestArgs{Spec: push.SpecArgs{Intent: "train"}} + if err := runInteractive(discardPrinter(), f, a, false); err != nil { t.Fatalf("runInteractive: %v", err) } - for _, l := range f.asked { - if l == "Label column" { - t.Errorf("masked_language_modeling should not prompt for a label column") - } + if a.LocalPath != dir { + t.Errorf("LocalPath = %q, want %q (surrounding whitespace not trimmed)", a.LocalPath, dir) } - if a.Spec.Table != "mlm_train" || a.Spec.Intent != "train" { - t.Errorf("table/intent not filled: %+v", a.Spec) + // The trimmed path must have sniffed cleanly as tabular (not landed in a + // cwd-prefixed nonexistent dir that would force the family question). + if a.Spec.Category != "tabular_classification" { + t.Errorf("Category = %q, want tabular_classification (sniff read the trimmed path)", a.Spec.Category) } } -// TestRunInteractive_RejectsBadTable: the table prompt runs -// push.ValidateTableName, so an unsafe name surfaces as an error. -func TestRunInteractive_RejectsBadTable(t *testing.T) { - f := &fakePrompter{answers: map[string]string{"Destination table name": "../bad"}} - a := &runDataIngestArgs{ - LocalPath: "./data", - Spec: push.SpecArgs{Category: "image_classification", Intent: "train", LabelColumn: "label"}, +// TestRunInteractive_ShowsExampleHints: the name and path prompts carry a +// visible example, so the guided flow teaches as it goes. +func TestRunInteractive_ShowsExampleHints(t *testing.T) { + dir := tabularDir(t) + f := &fakePrompter{answers: map[string]string{ + "What should we call this dataset?": "churn_train", + "Which column holds the class?": "churned", + }} + a := &runDataIngestArgs{LocalPath: dir, Spec: push.SpecArgs{Intent: "train"}} + var buf bytes.Buffer + p := ui.New(&buf, ui.WithColor(false)) + if err := runInteractive(p, f, a, false); err != nil { + t.Fatalf("runInteractive: %v", err) } - if err := runInteractive(discardPrinter(), f, a, true); err == nil { - t.Fatal("expected an error for an invalid table name, got nil") + for _, want := range []string{"e.g. churn_train", "age:INT"} { + if !strings.Contains(buf.String(), want) { + t.Errorf("interactive output missing hint %q:\n%s", want, buf.String()) + } + } +} + +// --- small assertion helpers ------------------------------------------- + +func contains(hay []string, needle string) bool { + for _, h := range hay { + if h == needle { + return true + } + } + return false +} + +// orderedSubsequence reports whether want appears in got in order (not +// necessarily contiguously). +func orderedSubsequence(got, want []string) bool { + i := 0 + for _, g := range got { + if i < len(want) && g == want[i] { + i++ + } } + return i == len(want) } diff --git a/internal/push/category.go b/internal/push/category.go index 32fa2e92..9edec1f8 100644 --- a/internal/push/category.go +++ b/internal/push/category.go @@ -21,10 +21,27 @@ type CategorySpec struct { Family Family // Label is the human-friendly name shown in the interactive picker. Label string + // Gloss, when set, is the name users actually search for — it wins over + // Label in the picker. A few tasks have a technical id + label but a + // far more recognizable common name (time_to_event_prediction is + // "survival analysis"; masked_language_modeling is "fill-mask"; + // seq2seq is "translation / summarization"). Empty ⇒ show Label. + Gloss string + // Blurb is the one-line "what is this for?" shown after the display name + // in the picker ("Display — blurb · task_id"). Plain and concrete so a + // user can tell tasks apart without leaving the terminal. + Blurb string // RegressionClass marks categories that predict a numeric target and // therefore need label.policy (object label form) so the raw target // never ships to the central backend by default. RegressionClass bool + // SelfSupervised marks text categories that train without an explicit + // label column — the target is derived from the text itself (MLM masks + // tokens; CLM predicts the next token), so the interactive flow skips + // the "which column is the label?" question. A registry fact rather + // than a hardcoded id list so a new self-supervised task can't be added + // without deciding this (SelfSupervisedText reads it). + SelfSupervised bool // CLISupported reports whether `dataset push` implements the category // today. semantic_segmentation is known (the schema defines it) but // not yet pushable. @@ -59,26 +76,41 @@ const ( // nor carry an extra the ingestor won't accept (the instance_segmentation // half-ingest class — data-ingestors #240/#99, #1005). var categoryRegistry = []CategorySpec{ - {ID: "image_classification", Family: FamilyImage, Label: "Image classification", CLISupported: true}, - {ID: "object_detection", Family: FamilyImage, Label: "Object detection", CLISupported: true}, - {ID: "keypoint_detection", Family: FamilyImage, Label: "Keypoint detection", CLISupported: true}, - {ID: "text_classification", Family: FamilyText, Label: "Text classification", CLISupported: true}, - {ID: "masked_language_modeling", Family: FamilyText, Label: "Masked language modeling", CLISupported: true}, - {ID: "tabular_classification", Family: FamilyTabular, Label: "Tabular classification", CLISupported: true}, - {ID: "tabular_regression", Family: FamilyTabular, Label: "Tabular regression", RegressionClass: true, CLISupported: true}, - {ID: "time_series_forecasting", Family: FamilyTabular, Label: "Time-series forecasting", RegressionClass: true, CLISupported: true}, - {ID: "time_to_event_prediction", Family: FamilyTabular, Label: "Time-to-event prediction", RegressionClass: true, CLISupported: true}, + {ID: "image_classification", Family: FamilyImage, Label: "Image classification", CLISupported: true, + Blurb: "sort images into classes"}, + {ID: "object_detection", Family: FamilyImage, Label: "Object detection", CLISupported: true, + Blurb: "draw boxes around objects in an image"}, + {ID: "keypoint_detection", Family: FamilyImage, Label: "Keypoint detection", CLISupported: true, + Blurb: "locate landmark points on an image (e.g. pose)"}, + {ID: "text_classification", Family: FamilyText, Label: "Text classification", CLISupported: true, + Blurb: "sort text snippets into classes"}, + {ID: "masked_language_modeling", Family: FamilyText, Label: "Masked language modeling", Gloss: "fill-mask", CLISupported: true, SelfSupervised: true, + Blurb: "predict masked-out words — no labels needed"}, + {ID: "tabular_classification", Family: FamilyTabular, Label: "Tabular classification", CLISupported: true, + Blurb: "predict a class from table columns"}, + {ID: "tabular_regression", Family: FamilyTabular, Label: "Tabular regression", RegressionClass: true, CLISupported: true, + Blurb: "predict a number from table columns"}, + {ID: "time_series_forecasting", Family: FamilyTabular, Label: "Time-series forecasting", RegressionClass: true, CLISupported: true, + Blurb: "predict future values from past ones"}, + {ID: "time_to_event_prediction", Family: FamilyTabular, Label: "Time-to-event prediction", Gloss: "Survival analysis", RegressionClass: true, CLISupported: true, + Blurb: "predict how long until an event happens"}, {ID: "semantic_segmentation", Family: FamilyImage, Label: "Semantic segmentation", CLISupported: false, + Blurb: "label every pixel in an image", UnsupportedNote: "blocked on the ingestor's mask-sidecar support (data-ingestors#136)"}, - {ID: "causal_language_modeling", Family: FamilyText, Label: "Causal language modeling", CLISupported: false, + {ID: "causal_language_modeling", Family: FamilyText, Label: "Causal language modeling", CLISupported: false, SelfSupervised: true, + Blurb: "predict the next word in a sequence", UnsupportedNote: "schema-recognized (data-ingestors#805); `tracebloc ingest` discover/build for its raw-.txt / prompt\\tcompletion `texts` layout is pending"}, - {ID: "seq2seq", Family: FamilyText, Label: "Sequence-to-sequence", CLISupported: false, + {ID: "seq2seq", Family: FamilyText, Label: "Sequence-to-sequence", Gloss: "translation / summarization", CLISupported: false, + Blurb: "map an input sequence to an output one", UnsupportedNote: "schema-recognized; `tracebloc ingest` discover/build for its raw-.txt / source\\ttarget `texts` layout is pending"}, {ID: "token_classification", Family: FamilyText, Label: "Token classification", CLISupported: false, + Blurb: "label each word in a sequence", UnsupportedNote: "schema-recognized; the CLI doesn't stage its per-token-label `texts` layout yet"}, {ID: "sentence_pair_classification", Family: FamilyText, Label: "Sentence-pair classification", CLISupported: false, + Blurb: "label how two texts relate", UnsupportedNote: "schema-recognized; `tracebloc ingest` discover/build for its raw-.txt / text_a\\ttext_b `texts` layout is pending"}, {ID: "embeddings", Family: FamilyText, Label: "Embeddings", CLISupported: false, + Blurb: "learn vector representations from text pairs", UnsupportedNote: "schema-recognized; `tracebloc ingest` discover/build for its raw-.txt / anchor\\tpositive[\\tnegative] `texts` layout is pending"}, } @@ -97,6 +129,94 @@ func Lookup(category string) (CategorySpec, bool) { return c, ok } +// DisplayName is the name to show a user: the recognizable Gloss when a +// task has one, otherwise the Label. Kept a method so the picker never +// re-derives the gloss-vs-label rule itself. +func (c CategorySpec) DisplayName() string { + if c.Gloss != "" { + return c.Gloss + } + return c.Label +} + +// CategoriesByFamily returns every registry spec in fam, in registry +// (display) order — CLI-supported first, then the not-yet-implemented +// ones. The data-first picker calls this once the family is known so it +// only ever offers that family's tasks, never the flat 15-item wall. +func CategoriesByFamily(fam Family) []CategorySpec { + out := make([]CategorySpec, 0, len(categoryRegistry)) + for _, c := range categoryRegistry { + if c.Family == fam { + out = append(out, c) + } + } + return out +} + +// familyNounTable is the single source of truth pairing each Family with the +// plain word shown in prompts, echoes, and the interactive family picker. The +// slice order is the picker's display order — tabular first, since it's the +// most common family and the default when the layout sniff is ambiguous. That +// order is deliberately NOT the Family iota order (which is layout-internal). +// FamilyNoun (forward), FamilyFromNoun (reverse), and FamilyNouns (picker +// options + default) all derive from this one table, so they can't drift apart. +var familyNounTable = []struct { + family Family + noun string +}{ + {FamilyTabular, "tabular"}, + {FamilyImage, "image"}, + {FamilyText, "text"}, +} + +// FamilyNoun is the plain word for a family, used in prompts and echoes +// ("tasks for tabular data", "this is image data"). Falls back to the picker +// default ("tabular") for an unrecognized family. +func FamilyNoun(fam Family) string { + for _, e := range familyNounTable { + if e.family == fam { + return e.noun + } + } + return "tabular" +} + +// FamilyFromNoun maps a family noun ("image"/"text"/"tabular") back to its +// Family — the reverse of FamilyNoun. Unrecognized input falls back to +// FamilyTabular, matching the picker default so a stray answer degrades to the +// safe common case. +func FamilyFromNoun(noun string) Family { + for _, e := range familyNounTable { + if e.noun == noun { + return e.family + } + } + return FamilyTabular +} + +// FamilyNouns returns the family nouns in picker/display order; the first +// element is the choice the picker pre-selects. The interactive family +// prompt derives both its options and its default from here. +func FamilyNouns() []string { + nouns := make([]string, len(familyNounTable)) + for i, e := range familyNounTable { + nouns[i] = e.noun + } + return nouns +} + +// SelfSupervisedText reports whether a text category trains without an +// explicit label column — the target is derived from the text itself, so +// the CLI skips the "which column is the label?" question. MLM masks +// tokens; CLM predicts the next token; neither reads a labels column. The +// answer is the registry's SelfSupervised flag, so a new self-supervised +// task is handled the moment it's added to the registry — not when someone +// remembers to edit this function. +func SelfSupervisedText(category string) bool { + c, ok := categoryByID[category] + return ok && c.SelfSupervised +} + // IsKnown reports whether category is a recognized task category (in the // schema), supported by the CLI or not. func IsKnown(category string) bool { diff --git a/internal/push/preview.go b/internal/push/preview.go new file mode 100644 index 00000000..39a9da5a --- /dev/null +++ b/internal/push/preview.go @@ -0,0 +1,213 @@ +package push + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// FamilySniff is the result of a thin, read-only layout preview: which +// family the data looks like, whether we're confident enough to skip +// asking, and a friendly one-liner to echo back when we are. +// +// It is a HINT, not a lock (RFC-0002 §5.1): an explicit --task always +// wins and skips the sniff entirely, and an ambiguous layout (Confident +// == false) means "ask the user plainly" rather than guessing. The sniff +// never validates — it only looks for the layout markers Discover* +// keys on, so it can't drift from (or replace) the real walk that runs +// later. If the sniff and the real walk ever disagree, the walk wins and +// reports the real error. +type FamilySniff struct { + Family Family + Confident bool + // Echo is the plain-language confirmation for the confident case, + // e.g. "Found a CSV table — this is tabular data." Empty when not + // confident. + Echo string +} + +// SniffFamily previews the family of the dataset at path by looking for +// the same layout markers Discover / DiscoverText / DiscoverTabular key +// on — labels.csv + an images/ dir (image), labels.csv + a texts/ or +// sequences/ dir (text), or exactly one CSV in a directory with none of +// those (tabular). It reads directory entries only; it opens no files and +// validates nothing. +// +// It never claims more than the matching Discover* would accept: the +// marker directories (images/, texts/, sequences/) and labels.csv are +// matched with the SAME literal, case-sensitive names the walk joins and +// Lstats — a mis-cased "Images/" is not the walk's marker, so it is not +// sniffed as confident image. Image / text are confident only when BOTH +// labels.csv AND the subdir are present, mirroring Discover / DiscoverText. +// Tabular is confident only on EXACTLY ONE CSV, mirroring DiscoverTabular's +// findSingleCSV count rule — a directory with two or more CSVs is a layout +// the tabular walk refuses, so the sniff must not confidently place it +// either. Only the .csv extension match stays case-insensitive, mirroring +// DiscoverTabular's EqualFold. +// +// Every family's walk requires a directory (bare-file support is +// cli#181), so a file path is never a confident sniff. Anything we can't +// place — a missing path, a bare file, a directory with no recognizable +// marker, an image+text mix, an image/text dir without labels.csv, a +// multi-CSV directory the tabular walk would reject — comes back +// Confident=false so the caller asks the family plainly. +func SniffFamily(path string) FamilySniff { + abs, err := filepath.Abs(path) + if err != nil { + return FamilySniff{} + } + st, err := os.Stat(abs) + if err != nil { + // Missing / unreadable: can't sniff. The real walk will produce the + // actionable error once a task is chosen. + return FamilySniff{} + } + + // Every family's walk requires a directory; a bare file (even a .csv) + // is rejected by DiscoverTabular until cli#181 adds bare-file support. + // Stay ambiguous so the caller asks the family plainly rather than + // promising a layout the walk would refuse. + if !st.IsDir() { + return FamilySniff{} + } + + entries, err := os.ReadDir(abs) + if err != nil { + return FamilySniff{} + } + // Match the walk's markers with the literal, case-sensitive names it + // uses (filepath.Join + os.Lstat on "images" / "texts" / "sequences" / + // "labels.csv"). The .csv extension check is case-insensitive to mirror + // DiscoverTabular's EqualFold. + var hasImages, hasTexts, hasSequences, hasLabels bool + // miscasedMarker flags a subdir that matches a marker name only + // case-insensitively (e.g. "Images", "Texts") — a likely mis-cased + // media folder. The walk keys on the literal lowercase name, so such a + // dir is NOT a marker to it; but its lone labels.csv would otherwise + // fall through to the confident-tabular branch and get silently + // ingested as a table, images/texts dropped. When we see one, stay + // ambiguous and ask the family plainly. + miscasedMarker := false + csvCount := 0 + for _, e := range entries { + name := e.Name() + if e.IsDir() { + switch name { + case "images": + hasImages = true + case "texts": + hasTexts = true + case "sequences": + hasSequences = true + default: + if isMarkerFold(name) { + miscasedMarker = true + } + } + continue + } + if name == "labels.csv" { + hasLabels = true + } + if strings.EqualFold(filepath.Ext(name), ".csv") { + csvCount++ + } + } + hasText := hasTexts || hasSequences + + // An images/ directory is the image layout's tell; a texts/ or + // sequences/ directory is the text family's. Both require labels.csv + // (as Discover / DiscoverText do). If a tree has both marker dirs + // (unusual), stay ambiguous rather than guess. + switch { + case hasImages && !hasText && hasLabels: + return FamilySniff{Family: FamilyImage, Confident: true, + Echo: "Found labels.csv and an images/ folder — this is image data."} + case hasText && !hasImages && hasLabels: + dir := "texts/" + if hasSequences { + dir = "sequences/" + } + // "looks like", not "is": the family is confident, but texts/ and + // sequences/ map to DIFFERENT tasks (DiscoverText keys the sidecar + // dir off TextSidecarDir — texts/ for classification, sequences/ for + // MLM). So a confident text sniff must not imply the task the user + // then picks will load; the picker offers the whole text family, and + // the walk gives the authoritative error if the layout and the + // chosen task disagree. + return FamilySniff{Family: FamilyText, Confident: true, + Echo: fmt.Sprintf("Found labels.csv and a %s folder — this looks like text data.", dir)} + case !hasImages && !hasText && !miscasedMarker && csvCount == 1: + // Exactly one CSV, mirroring DiscoverTabular's findSingleCSV rule. + // Two or more CSVs is a directory the tabular walk rejects, so stay + // ambiguous rather than confidently promise a layout it refuses. + // A mis-cased marker dir alongside the CSV (miscasedMarker) also + // bails to ambiguous: the lone labels.csv of an image/text layout + // whose media folder was mis-cased must not masquerade as a table. + return FamilySniff{Family: FamilyTabular, Confident: true, + Echo: "Found a CSV table — this is tabular data."} + default: + return FamilySniff{} + } +} + +// isMarkerFold reports whether name is one of the media-folder markers +// (images / texts / sequences) ignoring case. Used only to detect a +// mis-cased marker dir; the confident image/text branches still require an +// EXACT match, mirroring the walk's literal os.Lstat. +func isMarkerFold(name string) bool { + for _, m := range []string{"images", "texts", "sequences"} { + if strings.EqualFold(name, m) { + return true + } + } + return false +} + +// PreviewLabelHeaders returns the column names of the CSV a label column +// would be chosen from, so the interactive flow can offer the REAL header +// row instead of free text — an exact-match choice that kills the +// case-mismatch silent-null-label class (data-ingestors#340). It's a +// preview read: it locates the CSV the way the matching Discover* would +// (the single CSV for tabular — or the file itself if a file was passed — +// labels.csv for image / text) and reads only its header. +// +// It validates nothing; any failure (no CSV, unreadable, empty) comes +// back as an error the caller treats as "fall back to free-text entry", +// never a hard stop. +func PreviewLabelHeaders(category, root string) ([]string, error) { + csvPath, err := previewLabelCSVPath(category, root) + if err != nil { + return nil, err + } + return ReadCSVHeader(csvPath) +} + +// previewLabelCSVPath resolves which CSV holds the label column for a +// category's layout, mirroring the Discover* file conventions without +// re-validating them. +func previewLabelCSVPath(category, root string) (string, error) { + abs, err := filepath.Abs(root) + if err != nil { + return "", err + } + if !IsTabular(category) { + // image / text families: the label lives in labels.csv. + return filepath.Join(abs, "labels.csv"), nil + } + // Tabular: the dataset IS a single CSV. Accept a direct file path (the + // "point at your table" case) or resolve the lone .csv in a directory + // via the SAME single-CSV rule DiscoverTabular enforces — including its + // exactly-one requirement, so a multi-CSV directory errors here (and the + // caller falls back to free-text entry) instead of silently reading the + // alphabetically-first file's header. + st, err := os.Stat(abs) + if err != nil { + return "", err + } + if !st.IsDir() { + return abs, nil + } + return findSingleCSV(abs) +} diff --git a/internal/push/preview_test.go b/internal/push/preview_test.go new file mode 100644 index 00000000..17b72f4f --- /dev/null +++ b/internal/push/preview_test.go @@ -0,0 +1,271 @@ +package push + +import ( + "os" + "path/filepath" + "testing" +) + +func writePrev(t *testing.T, path, body string) { + t.Helper() + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +// TestSniffFamily covers each confident layout + the ambiguous fallbacks. +func TestSniffFamily(t *testing.T) { + t.Run("tabular dir (single csv)", func(t *testing.T) { + dir := t.TempDir() + writePrev(t, filepath.Join(dir, "data.csv"), "a,b\n1,2\n") + s := SniffFamily(dir) + if !s.Confident || s.Family != FamilyTabular { + t.Fatalf("got %+v, want confident tabular", s) + } + if s.Echo == "" { + t.Error("confident sniff should carry an echo") + } + }) + + t.Run("multi-csv dir is ambiguous (walk rejects >1 csv)", func(t *testing.T) { + // DiscoverTabular's findSingleCSV requires exactly one CSV; a dir with + // two would fail the walk, so the sniff must not confidently place it + // as tabular (it would echo "this is tabular data" then the walk would + // reject). Mirrors the walk's exactly-one rule. + dir := t.TempDir() + writePrev(t, filepath.Join(dir, "a.csv"), "x\n1\n") + writePrev(t, filepath.Join(dir, "b.csv"), "y\n2\n") + if s := SniffFamily(dir); s.Confident { + t.Fatalf("a multi-csv dir should be ambiguous, got %+v", s) + } + // And the walk it mirrors does reject it. + if _, err := DiscoverTabular(dir); err == nil { + t.Fatal("DiscoverTabular should reject a multi-csv dir") + } + }) + + t.Run("bare .csv file is ambiguous (walk requires a directory)", func(t *testing.T) { + // DiscoverTabular rejects a bare file (bare-file support is cli#181), + // so the sniff must not confidently place a lone .csv — otherwise it + // promises a layout the walk refuses. + dir := t.TempDir() + csv := filepath.Join(dir, "t.csv") + writePrev(t, csv, "a,b\n1,2\n") + if s := SniffFamily(csv); s.Confident { + t.Fatalf("a bare .csv file should be ambiguous, got %+v", s) + } + }) + + t.Run("mis-cased Images/ + labels.csv is ambiguous, NOT confident tabular", func(t *testing.T) { + // Discover Lstats the literal "images"; a mis-cased "Images/" is not + // its marker. The sniff must not claim confident image — but it must + // ALSO not fall through to confident tabular, or the lone labels.csv + // of a mis-cased image layout would be silently ingested as a table + // (cli#203). It stays ambiguous so the flow asks the family plainly. + dir := t.TempDir() + writePrev(t, filepath.Join(dir, "labels.csv"), "image_id,label\n1.jpg,c\n") + if err := os.Mkdir(filepath.Join(dir, "Images"), 0o755); err != nil { + t.Fatal(err) + } + if s := SniffFamily(dir); s.Confident { + t.Fatalf("mis-cased Images/ + labels.csv must be ambiguous, got %+v", s) + } + }) + + t.Run("mis-cased Texts/ + labels.csv is ambiguous, NOT confident tabular", func(t *testing.T) { + dir := t.TempDir() + writePrev(t, filepath.Join(dir, "labels.csv"), "text_id,label\n1.txt,c\n") + if err := os.Mkdir(filepath.Join(dir, "Texts"), 0o755); err != nil { + t.Fatal(err) + } + if s := SniffFamily(dir); s.Confident { + t.Fatalf("mis-cased Texts/ + labels.csv must be ambiguous, got %+v", s) + } + }) + + t.Run("single csv + unrelated subdir stays confident tabular", func(t *testing.T) { + // The mis-cased guard must be narrow: a subdir that is NOT a marker + // name (case-insensitively) — a stray backup/ etc. — must not derail + // the confident-tabular sniff, since DiscoverTabular ignores it too. + dir := t.TempDir() + writePrev(t, filepath.Join(dir, "data.csv"), "a,b\n1,2\n") + if err := os.Mkdir(filepath.Join(dir, "backup"), 0o755); err != nil { + t.Fatal(err) + } + s := SniffFamily(dir) + if !s.Confident || s.Family != FamilyTabular { + t.Fatalf("single csv + unrelated subdir should stay confident tabular, got %+v", s) + } + }) + + t.Run("images/ without labels.csv is not confident image", func(t *testing.T) { + // Discover requires BOTH labels.csv and images/; without labels.csv it + // errors, so the sniff must not claim confident image on the subdir + // alone. + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "images"), 0o755); err != nil { + t.Fatal(err) + } + if s := SniffFamily(dir); s.Confident && s.Family == FamilyImage { + t.Fatalf("images/ without labels.csv must not be confident image, got %+v", s) + } + }) + + t.Run("texts/ without labels.csv is not confident text", func(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "texts"), 0o755); err != nil { + t.Fatal(err) + } + if s := SniffFamily(dir); s.Confident && s.Family == FamilyText { + t.Fatalf("texts/ without labels.csv must not be confident text, got %+v", s) + } + }) + + t.Run("image dir", func(t *testing.T) { + dir := t.TempDir() + writePrev(t, filepath.Join(dir, "labels.csv"), "image_id,label\n1.jpg,c\n") + if err := os.Mkdir(filepath.Join(dir, "images"), 0o755); err != nil { + t.Fatal(err) + } + s := SniffFamily(dir) + if !s.Confident || s.Family != FamilyImage { + t.Fatalf("got %+v, want confident image", s) + } + }) + + t.Run("text dir (sequences)", func(t *testing.T) { + dir := t.TempDir() + writePrev(t, filepath.Join(dir, "labels.csv"), "text_id,label\n1.txt,c\n") + if err := os.Mkdir(filepath.Join(dir, "sequences"), 0o755); err != nil { + t.Fatal(err) + } + s := SniffFamily(dir) + if !s.Confident || s.Family != FamilyText { + t.Fatalf("got %+v, want confident text", s) + } + }) + + t.Run("empty dir is ambiguous", func(t *testing.T) { + if s := SniffFamily(t.TempDir()); s.Confident { + t.Fatalf("empty dir should be ambiguous, got %+v", s) + } + }) + + t.Run("image+text mix is ambiguous", func(t *testing.T) { + dir := t.TempDir() + writePrev(t, filepath.Join(dir, "labels.csv"), "x\n1\n") + _ = os.Mkdir(filepath.Join(dir, "images"), 0o755) + _ = os.Mkdir(filepath.Join(dir, "texts"), 0o755) + if s := SniffFamily(dir); s.Confident { + t.Fatalf("an images/+texts/ mix should be ambiguous, got %+v", s) + } + }) + + t.Run("missing path is ambiguous", func(t *testing.T) { + if s := SniffFamily(filepath.Join(t.TempDir(), "nope")); s.Confident { + t.Fatalf("missing path should be ambiguous, got %+v", s) + } + }) +} + +// TestPreviewLabelHeaders reads the header from the right CSV per family. +func TestPreviewLabelHeaders(t *testing.T) { + t.Run("tabular reads the single csv", func(t *testing.T) { + dir := t.TempDir() + writePrev(t, filepath.Join(dir, "data.csv"), "age,income,churned\n1,2,yes\n") + hdr, err := PreviewLabelHeaders("tabular_classification", dir) + if err != nil { + t.Fatal(err) + } + if len(hdr) != 3 || hdr[2] != "churned" { + t.Fatalf("headers = %v, want [age income churned]", hdr) + } + }) + + t.Run("image reads labels.csv", func(t *testing.T) { + dir := t.TempDir() + writePrev(t, filepath.Join(dir, "labels.csv"), "image_id,label\n1.jpg,c\n") + hdr, err := PreviewLabelHeaders("image_classification", dir) + if err != nil { + t.Fatal(err) + } + if len(hdr) != 2 || hdr[1] != "label" { + t.Fatalf("headers = %v, want [image_id label]", hdr) + } + }) + + t.Run("missing csv errors (caller falls back to free text)", func(t *testing.T) { + if _, err := PreviewLabelHeaders("tabular_classification", t.TempDir()); err == nil { + t.Fatal("expected an error when no csv is present") + } + }) + + t.Run("multi-csv errors instead of silently picking the first", func(t *testing.T) { + // DiscoverTabular rejects a directory with more than one CSV; the + // preview must mirror that (not silently read the alphabetically-first + // header) so the caller falls back to free text rather than offering + // columns from a CSV the walk will reject. + dir := t.TempDir() + writePrev(t, filepath.Join(dir, "a.csv"), "x\n1\n") + writePrev(t, filepath.Join(dir, "b.csv"), "y\n2\n") + if _, err := PreviewLabelHeaders("tabular_classification", dir); err == nil { + t.Fatal("expected an error for a multi-csv tabular directory") + } + // Same rule the walk enforces. + if _, err := DiscoverTabular(dir); err == nil { + t.Fatal("DiscoverTabular should also reject a multi-csv directory") + } + }) +} + +// TestDisplayNameGlosses pins the locked glosses win over the label, and a +// task without a gloss shows its label. +func TestDisplayNameGlosses(t *testing.T) { + cases := map[string]string{ + "time_to_event_prediction": "Survival analysis", + "masked_language_modeling": "fill-mask", + "seq2seq": "translation / summarization", + "image_classification": "Image classification", // no gloss → label + } + for id, want := range cases { + spec, ok := Lookup(id) + if !ok { + t.Fatalf("Lookup(%q) not found", id) + } + if got := spec.DisplayName(); got != want { + t.Errorf("DisplayName(%q) = %q, want %q", id, got, want) + } + } +} + +// TestCategoriesByFamily returns only that family, and every spec carries a +// blurb (so the picker line is never "Display — · id"). +func TestCategoriesByFamily(t *testing.T) { + for _, fam := range []Family{FamilyImage, FamilyText, FamilyTabular} { + got := CategoriesByFamily(fam) + if len(got) == 0 { + t.Fatalf("family %d has no categories", fam) + } + for _, c := range got { + if c.Family != fam { + t.Errorf("CategoriesByFamily(%d) returned %q from family %d", fam, c.ID, c.Family) + } + if c.Blurb == "" { + t.Errorf("category %q has no blurb", c.ID) + } + } + } +} + +func TestSelfSupervisedText(t *testing.T) { + for _, id := range []string{"masked_language_modeling", "causal_language_modeling"} { + if !SelfSupervisedText(id) { + t.Errorf("%s should be self-supervised", id) + } + } + for _, id := range []string{"text_classification", "tabular_regression", "image_classification"} { + if SelfSupervisedText(id) { + t.Errorf("%s should not be self-supervised", id) + } + } +} diff --git a/internal/push/tabular.go b/internal/push/tabular.go index d8aa87d3..ecc14589 100644 --- a/internal/push/tabular.go +++ b/internal/push/tabular.go @@ -48,23 +48,16 @@ const schemaInferenceSampleRows = 5000 // The returned LocalLayout reuses the image layout's LabelsCSV field // (staged as labels.csv) with an empty Images slice, so the existing // tar/stream machinery handles it unchanged. -func DiscoverTabular(rootDir string) (*LocalLayout, error) { - abs, err := filepath.Abs(rootDir) +// findSingleCSV resolves the one .csv file a tabular layout must hold in +// dir, enforcing DiscoverTabular's exactly-one rule: zero or multiple CSVs +// are errors with the same framing. dir must already be known to be a +// directory. Factored out so the interactive label-header preview +// (previewLabelCSVPath) locates the same CSV the walk would, and can never +// drift from — or silently soften — the count rule. +func findSingleCSV(dir string) (string, error) { + entries, err := os.ReadDir(dir) if err != nil { - return nil, fmt.Errorf("resolving %q: %w", rootDir, err) - } - st, err := os.Stat(abs) - if err != nil { - return nil, fmt.Errorf("reading dataset directory %q: %w", abs, err) - } - if !st.IsDir() { - return nil, fmt.Errorf( - "%q is not a directory; pass the directory containing the dataset CSV", abs) - } - - entries, err := os.ReadDir(abs) - if err != nil { - return nil, fmt.Errorf("reading %q: %w", abs, err) + return "", fmt.Errorf("reading %q: %w", dir, err) } var csvs []string for _, e := range entries { @@ -78,31 +71,50 @@ func DiscoverTabular(rootDir string) (*LocalLayout, error) { sort.Strings(csvs) switch len(csvs) { case 0: - return nil, fmt.Errorf( + return "", fmt.Errorf( "no .csv file found in %q. Tabular / time-series categories expect a "+ "single CSV holding the dataset (one column per feature, plus the "+ - "label column).", abs) + "label column).", dir) case 1: - // happy path + return filepath.Join(dir, csvs[0]), nil default: - return nil, fmt.Errorf( + return "", fmt.Errorf( "found %d .csv files in %q (%s); the tabular layout expects exactly one. "+ "Put the dataset CSV in its own directory and re-run.", - len(csvs), abs, strings.Join(csvs, ", ")) + len(csvs), dir, strings.Join(csvs, ", ")) + } +} + +func DiscoverTabular(rootDir string) (*LocalLayout, error) { + abs, err := filepath.Abs(rootDir) + if err != nil { + return nil, fmt.Errorf("resolving %q: %w", rootDir, err) + } + st, err := os.Stat(abs) + if err != nil { + return nil, fmt.Errorf("reading dataset directory %q: %w", abs, err) + } + if !st.IsDir() { + return nil, fmt.Errorf( + "%q is not a directory; pass the directory containing the dataset CSV", abs) } - csvPath := filepath.Join(abs, csvs[0]) + csvPath, err := findSingleCSV(abs) + if err != nil { + return nil, err + } + csvName := filepath.Base(csvPath) // Lstat (not Stat) so a symlinked CSV is rejected rather than // silently followed — mirrors the image layout's symlink guard. info, err := os.Lstat(csvPath) if err != nil { - return nil, fmt.Errorf("stat %s: %w", csvs[0], err) + return nil, fmt.Errorf("stat %s: %w", csvName, err) } - if err := rejectSymlink(info, csvs[0]); err != nil { + if err := rejectSymlink(info, csvName); err != nil { return nil, err } if info.Size() > MaxSingleFileBytes { - return nil, sizeError(csvs[0], info.Size(), MaxSingleFileBytes) + return nil, sizeError(csvName, info.Size(), MaxSingleFileBytes) } layout := &LocalLayout{Root: abs, LabelsCSV: csvPath, TotalBytes: info.Size()} From 767fba3242e9efc4e1aaa8ac8b393111197f9a32 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:07:17 +0200 Subject: [PATCH 05/22] =?UTF-8?q?fix(data=20ingest):=20#197=20review=20fol?= =?UTF-8?q?low-ups=20=E2=80=94=20picker=20crash=20+=20README/help/label=20?= =?UTF-8?q?(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(data ingest): address #197 code-review findings - interactive picker no longer passes an empty default to survey.Select after --task's default was dropped (crashed "omit --task to pick interactively" on a TTY); falls back to the first task. - rename the guided name prompt "Destination table name" -> "Dataset name" to match --name / the Review + summary labels (finish the #180 rename). - --name flag help now states the leading letter/underscore rule the tightened validator enforces. - README quickstart: --split train -> --intent train (--split was removed; --intent stayed canonical). Co-Authored-By: Claude Opus 4.8 * fix(data ingest): finish the name-prompt rename + drop dead picker seed Two review follow-ups on #199: - The PromptHint above the "Dataset name" prompt still described "the table created on the cluster (and its folder on the shared storage). Letters, digits, underscores only" — it leaked the k8s/MySQL ceremony the rename was removing and stated the old rule (implying a leading digit is allowed, which ValidateTableName rejects). Reworded to plain language matching the flag help + validator. - The picker's default was seeded from a.Spec.Category, which is always "" in the !taskSet branch, so the len-guarded fallback always fired. Seed promptCategories[0] directly — the list is a fixed non-empty package var backed by the hardcoded category registry. go build ./..., go vet, gofmt -l clean; go test ./internal/cli/... ./internal/push/... green. Co-Authored-By: Claude Opus 4.8 * test(data ingest): cover the picker's default-seed path The #197 fix seeds the task picker with promptCategories[0] when the task is unset, since survey.Select rejects an empty default. No test drove taskSet=false with an empty category, so a reseed to "" would have kept the suite green while crashing a real terminal. This case omits the "Task" answer so the fake returns the seeded default, then asserts the category landed on promptCategories[0] — verified to fail if the seed regresses to "". Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Asad Iqbal --- README.md | 2 +- internal/cli/data.go | 2 +- internal/cli/interactive.go | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9dd71ff6..57d0ac8f 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ irm https://github.com/tracebloc/cli/releases/latest/download/install.ps1 | iex tracebloc data ingest ./my-data \ --name cats_dogs_train \ --task image_classification \ - --split train \ + --intent train \ --label-column label ``` diff --git a/internal/cli/data.go b/internal/cli/data.go index 89bdf348..f8965fac 100644 --- a/internal/cli/data.go +++ b/internal/cli/data.go @@ -261,7 +261,7 @@ Exit codes: // schema validator catches missing/empty values with the canonical // JSON-pointer-anchored error. cmd.Flags().StringVar(&name, "name", "", - "a name for this dataset (letters, digits, underscore) — you'll reference it by this name when you start a training run") + "a name for this dataset — start with a letter or underscore, then letters/digits/underscores — you'll reference it by this name when you start a training run") cmd.Flags().StringVar(&tableAlias, "table", "", "deprecated alias for --name") _ = cmd.Flags().MarkHidden("table") diff --git a/internal/cli/interactive.go b/internal/cli/interactive.go index 5f28b277..5e8eec20 100644 --- a/internal/cli/interactive.go +++ b/internal/cli/interactive.go @@ -123,9 +123,9 @@ func runInteractive(p *ui.Printer, pr prompter, a *runDataIngestArgs, taskSet bo // (b) name — no auto-fill: the example lives in the hint, so the user // types their own name rather than editing a pre-filled default. if a.Spec.Table == "" { - p.PromptHint("A name for this dataset — you'll reference it by this name when you start a training run. Letters, digits, underscores. e.g. churn_train") + p.PromptHint("A name for this dataset — you'll reference it by this name when you start a training run. Start with a letter or underscore, then letters, digits, underscores. e.g. churn_train") ans, err := pr.Input("What should we call this dataset?", - "MySQL identifier + PVC subdir; letters, digits, underscore only", "", + "MySQL identifier + PVC subdir; start with a letter or underscore, then letters, digits, underscore", "", push.ValidateTableName) if err != nil { return err From d57a6d5cee0a982c6a5cbbadea11d6620e3c4f1f Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:01:25 +0200 Subject: [PATCH 06/22] feat(data ingest): flexible file-or-folder input + path fixes (#181) (#202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(data ingest): accept a bare .csv + per-family help + path fixes (#181) RFC-0002 phase 3: flexible file-or-folder input, clearer instructions, and two path bugs. - Accept a bare .csv for the tabular / time-series family. DiscoverTabular now takes either a directory (its existing exactly-one-CSV rule) or a bare .csv file — both resolve to the SAME staged layout (the CSV staged as the one labels.csv under the dataset), so the ingestor's contract is unchanged. This is a CLI-side input convenience only; no data-ingestors change. Media/label families (image, text) stay directory-only and error clearly ("… is not a directory"). - Restore the sniff + copy #180b softened: SniffFamily is confident-tabular for a bare .csv again (mirroring the now file-or-folder-capable walk), and the interactive path prompt goes back to "Where is your data? (file or folder)" with a matching hint. - Per-family layout help: the ingest Long help + the data group help now show tabular (a .csv OR a folder with one .csv), image (labels.csv + images/), and text (labels.csv + texts/) instead of only image. - Rename the positional arg in Use/help. - expandHome now expands "~user" / "~user/…" via user.Lookup, not just "~" / "~/…"; an unknown/unlookupable user is left literal so the path check reports it plainly. - Check path existence BEFORE spec/schema/family validation, so a typo'd path fails on the path (exit 3, "no such file or directory") instead of a confusing downstream error (e.g. the task gate). Tests: bare .csv discovers + stages identically to a one-CSV dir; bare non-.csv and image/text bare files rejected; sniff confident-tabular on a bare .csv; "file or folder" copy restored; ~user expansion; nonexistent path beats the task gate with exit 3; arg name. Co-Authored-By: Claude Opus 4.8 * fix(data ingest): address #181 review findings — expandHome edge cases + path-check ordering Three verified review findings on the #181 flexible-input work: - expandHome mangled a ~user whose passwd home field is blank: user.Lookup succeeded with an empty HomeDir, so filepath.Join("", rest) produced a relative path instead of the documented clear literal-path error. Guard u.HomeDir == "" the same way the current-user branch already guards an empty $HOME. - The "path existence FIRST" invariant held only on the flag-only route. In guided mode runInteractive sniffed the family and previewed the label header at a non-existent path before the 0b guard ever fired, so the user answered the whole questionnaire against a bad path. Extract the guard into statDatasetPath and run it in runInteractive before the family sniff; a typed exitError from a guided step now propagates unwrapped (clean message, not "interactive setup: ..."). - expandHome (data ingest) and cluster.expandPath had drifted: #181 added ~user resolution to only one, so `--kubeconfig ~alice/.kube/config` silently treated alice as a subdir of the current user's home. Consolidate both into a shared internal/pathutil.ExpandHome (the promote-to-pathutil plan the old comments kept deferring) so ~-expansion is identical across every subcommand. Coverage floor unaffected (internal/cli 73.6% >= 68%). New pathutil package carries its own contract tests. Co-Authored-By: Claude Opus 4.8 * fix(data ingest): sniff/walk symlink parity + shared isCSV + stale test key Review polish on the #181 flexible-input work: - SniffFamily no longer confidently sniffs a symlinked .csv as tabular. os.Stat follows the link, but DiscoverTabular Lstats + rejectSymlinks the CSV, so a symlinked .csv is a layout the walk REFUSES. The sniff would lock the guided flow to tabular and then hard-fail on the walk's symlink guard — breaking SniffFamily's "never claims more than the matching Discover* would accept" contract. The bare-file branch now Lstats and stays ambiguous for a symlink, so sniff and walk agree. - Extract isCSV(name) and route all four .csv-extension checks through it (findSingleCSV, DiscoverTabular's bare-file branch, and both SniffFamily sites). The sniff and walk bare-file rules were copy-pasted case-fold checks the comments insist must stay in lockstep; one helper removes the drift risk. - Fix a stale fakePrompter key in TestRunInteractive_TrimsPath: the path prompt was renamed to "(file or folder)" everywhere except this one answer-map key, which still read "(the folder holding it)", so the fake returned "" and the test failed with "a dataset path is required". Tests: new SniffFamily case asserts sniff + walk both refuse a symlinked .csv. go build ./... && go test ./... && gofmt -l . && go vet ./... green. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Asad Iqbal --- internal/cli/coverage_test.go | 44 ++++++++++++- internal/cli/data.go | 109 +++++++++++++++++++++++-------- internal/cli/data_test.go | 79 ++++++++++++++++++++++ internal/cli/interactive.go | 14 +++- internal/cli/interactive_test.go | 52 +++++++++++---- internal/cluster/kubeconfig.go | 30 +++------ internal/pathutil/expand.go | 55 ++++++++++++++++ internal/pathutil/expand_test.go | 69 +++++++++++++++++++ internal/push/preview.go | 49 +++++++++----- internal/push/preview_test.go | 47 +++++++++++-- internal/push/tabular.go | 60 ++++++++++++----- internal/push/tabular_test.go | 82 +++++++++++++++++++++++ internal/push/text_test.go | 16 +++++ internal/push/walk_test.go | 18 +++++ 14 files changed, 625 insertions(+), 99 deletions(-) create mode 100644 internal/pathutil/expand.go create mode 100644 internal/pathutil/expand_test.go diff --git a/internal/cli/coverage_test.go b/internal/cli/coverage_test.go index 586ee645..c176166b 100644 --- a/internal/cli/coverage_test.go +++ b/internal/cli/coverage_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "os" + "os/user" "path/filepath" "strings" "testing" @@ -173,7 +174,10 @@ func TestClassifyPushOutcome(t *testing.T) { func TestRunDatasetPush_OutputJSONEarlyFailureEmitsJSON(t *testing.T) { var jsonBuf, human bytes.Buffer a := runDataIngestArgs{ - LocalPath: "./x", + // A real path so the failure is the invalid table name (exit 2), not + // the earlier path-existence check (exit 3, #181) — this test pins the + // stdout-always-JSON contract on the table-validation failure. + LocalPath: t.TempDir(), Spec: push.SpecArgs{Table: "../bad", Category: "image_classification", Intent: "train"}, Printer: ui.New(&human, ui.WithColor(false)), OutputJSON: true, @@ -219,6 +223,44 @@ func TestExpandHome(t *testing.T) { } } +// TestExpandHome_NamedUser covers the #181 ~user form: "~user" and +// "~user/…" resolve under that user's home. We look up the CURRENT user by +// name so the test doesn't depend on a fixed account existing, and compare +// against os.UserHomeDir. An unknown ~user is left literal (the path- +// existence check surfaces it), which we also pin. +func TestExpandHome_NamedUser(t *testing.T) { + u, err := user.Current() + if err != nil || u.Username == "" { + t.Skipf("no current user: %v", err) + } + // user.Lookup must resolve the same account (it can differ from + // UserHomeDir on some CI images); skip if it doesn't rather than assert + // on an environment quirk. + looked, err := user.Lookup(u.Username) + if err != nil { + t.Skipf("user.Lookup(%q) unsupported here: %v", u.Username, err) + } + home := looked.HomeDir + + cases := []struct{ in, want string }{ + {"~" + u.Username, home}, + {"~" + u.Username + "/data", filepath.Join(home, "data")}, + {"~" + u.Username + "/a/b", filepath.Join(home, "a", "b")}, + } + for _, c := range cases { + if got := expandHome(c.in); got != c.want { + t.Errorf("expandHome(%q) = %q, want %q", c.in, got, c.want) + } + } + + // An unknown user can't be resolved: the literal is returned unchanged so + // the downstream path-existence check reports it plainly. + const unknown = "~nsuchuser-tracebloc-181/x" + if got := expandHome(unknown); got != unknown { + t.Errorf("expandHome(%q) = %q, want it left literal", unknown, got) + } +} + // TestExitError_Methods pins the exit-code carrier: Error() surfaces // the wrapped message (or a fallback when nil), and Code() returns the // process exit code main() propagates. diff --git a/internal/cli/data.go b/internal/cli/data.go index f8965fac..d52742b8 100644 --- a/internal/cli/data.go +++ b/internal/cli/data.go @@ -15,6 +15,7 @@ import ( "gopkg.in/yaml.v3" "github.com/tracebloc/cli/internal/cluster" + "github.com/tracebloc/cli/internal/pathutil" "github.com/tracebloc/cli/internal/push" "github.com/tracebloc/cli/internal/schema" "github.com/tracebloc/cli/internal/submit" @@ -44,6 +45,11 @@ submits the ingestion run, and watches it to completion (streaming logs + the final summary). ` + "`data validate`" + ` checks an ingest.yaml locally first. +What a dataset looks like depends on the task: + tabular / time-series — a .csv file, or a folder with one .csv + image — a folder with labels.csv + images/ + text — a folder with labels.csv + texts/ + ` + "`tracebloc cluster info`" + ` is the pre-flight you'd typically run before the first ingest.`, // A bare `tracebloc data` prints help; a mistyped subcommand errors with a @@ -58,7 +64,7 @@ before the first ingest.`, return cmd } -// newDataIngestCmd implements `tracebloc data ingest `. +// newDataIngestCmd implements `tracebloc data ingest `. // // Phase 3 scope (now complete across PR-a + PR-b): // @@ -133,7 +139,7 @@ func newDataIngestCmd() *cobra.Command { ) cmd := &cobra.Command{ - Use: "ingest ", + Use: "ingest ", Aliases: []string{"push"}, Short: "Ingest a local dataset into your workspace", Long: `Ingests a local dataset into your workspace's storage, @@ -143,14 +149,36 @@ infrastructure. Supports 9 tasks (image classification, object/keypoint detection, text classification, masked language modeling, and the tabular / time-series family); pick one with --task. -Expected local layout (image_classification shown): + is the data itself. What it looks like depends on the task: + + tabular / time-series — the dataset is a single CSV. Pass the .csv + file directly, or a folder holding exactly one .csv: + + churn.csv (the .csv file itself) + or + churn/ + data.csv (the one .csv in the folder) + + image (classification, object/keypoint detection) — a folder with + labels.csv + an images/ subfolder: + + cats_dogs/ + labels.csv (required) + images/ (required) + 001.jpg + ... - / - labels.csv (required) - images/ (required) - 001.jpg - 002.jpg - ... + text (classification, masked language modeling) — a folder with + labels.csv + a texts/ subfolder: + + reviews/ + labels.csv (required) + texts/ (required) + 001.txt + ... + +A bare .csv file is accepted only for the tabular / time-series family; +image and text datasets must be a folder. Accepted image extensions: .jpg, .jpeg, or .png (case-insensitive). All images in one dataset must share a single type — the cluster @@ -358,25 +386,32 @@ type runDataIngestArgs struct { ImageDigest string } -// expandHome expands a leading ~ or ~/… to $HOME, leaving every other -// path (relative, absolute, empty) untouched. It mirrors -// cluster.expandPath — kept as a small local copy rather than coupling -// the data path-handling to the cluster package's internals; if a -// third caller appears, promote both to a shared pathutil. +// expandHome expands a leading ~ (current user or ~user) to a home +// directory, leaving every other path untouched. It's the CLI-local +// name for the shared pathutil.ExpandHome; cluster.expandPath resolves +// to the same helper, so ~-expansion is identical across subcommands +// (a --kubeconfig ~alice/... resolves alice's home just like a data +// ingest path does). See pathutil.ExpandHome for the full contract. (#181) func expandHome(path string) string { - if path == "" || path[0] != '~' { - return path - } - home, err := os.UserHomeDir() - if err != nil { - // Can't resolve $HOME — leave it and let the downstream - // Discover* error mention the literal path, which is more - // useful than a generic failure here. - return path + return pathutil.ExpandHome(path) +} + +// statDatasetPath is the "path existence FIRST" guard (#181): a typo'd +// path fails plainly on the path — a clean "no such file or directory" — +// before any family sniff, label preview, or schema work touches it. +// Both entry points call it: the flag-only path from runDataIngest's 0b +// step, and the guided path from runInteractive (before the family sniff), +// so the invariant holds on every route rather than only the flag path. +func statDatasetPath(path string) error { + if _, serr := os.Stat(path); serr != nil { + if errors.Is(serr, os.ErrNotExist) { + return &exitError{code: 3, err: fmt.Errorf( + "no such file or directory: %q — check the path to your dataset", path)} + } + return &exitError{code: 3, err: fmt.Errorf( + "can't read %q: %w", path, serr)} } - // path[1:] is "" for "~" (→ home) and "/x" for "~/x" (→ home/x); - // filepath.Join cleans the join either way. - return filepath.Join(home, path[1:]) + return nil } // runDataIngest is the full Phase 3 implementation: pre-flight @@ -437,6 +472,14 @@ collaborators can train against that table without ever seeing the raw files.`)) a.Printer.Infof("Cancelled — nothing was ingested.") return nil } + // A typed exitError from a guided step (e.g. the path-existence + // guard, which runInteractive runs before the family sniff) + // already carries its own code + clean message — surface it as-is + // rather than burying it under "interactive setup:". + var ee *exitError + if errors.As(err, &ee) { + return err + } return &exitError{code: 3, err: fmt.Errorf("interactive setup: %w", err)} } } @@ -460,6 +503,20 @@ collaborators can train against that table without ever seeing the raw files.`)) // before any push.Discover* call. (#37) a.LocalPath = expandHome(a.LocalPath) + // 0b. Path existence FIRST — before any spec / schema / family + // validation. A typo'd path should fail on the path with a plain + // "no such file or directory", not surface later as a confusing + // downstream error (e.g. the task gate asking which task the + // non-existent data is for). runInteractive runs this same guard + // before its family sniff / label preview, so the invariant holds on + // the guided route too; this re-check covers the flag-only path and + // is cheap (one stat). The family walk below stats again for its + // layout-specific diagnostics; this is only about ordering the first + // failure a customer sees. (#181) + if err := statDatasetPath(a.LocalPath); err != nil { + return err + } + // 1. Validate the table name BEFORE anything else. It's both // the MySQL identifier and the /data/shared/
/ PVC // subdirectory — an unsanitized traversal name (../../etc) diff --git a/internal/cli/data_test.go b/internal/cli/data_test.go index 2dfad932..387ab464 100644 --- a/internal/cli/data_test.go +++ b/internal/cli/data_test.go @@ -212,6 +212,85 @@ func TestDataIngest_NonexistentLocalPath_ExitsThree(t *testing.T) { } } +// TestDataIngest_NonexistentPath_BeatsTaskGate: a typo'd path must fail on +// the path (exit 3), NOT on a downstream spec/family error, even when the +// task is also wrong. Pins the #181 ordering fix: path existence is checked +// before the category gate (which would otherwise exit 2 for the bad task +// and send the user chasing the wrong problem). +func TestDataIngest_NonexistentPath_BeatsTaskGate(t *testing.T) { + code, _, _ := execDataIngest(t, []string{ + "/tmp/tracebloc-cli-test-no-such-dir-" + t.Name(), + "--name=t1", + "--task=definitely-not-a-task", // would be exit 2 at the task gate + "--intent=train", + }) + if code != 3 { + t.Fatalf("expected exit 3 (path checked before the task gate), got %d", code) + } +} + +// TestDataIngest_BareCSVFile_Accepted: a bare .csv is a valid tabular input +// (#181). It gets PAST the layout walk — proven by the "Inferred schema" +// line, which prints only after DiscoverTabular accepted the file — and then +// falls through the local checks to the injected bad kubeconfig (exit 3), +// the same fall-through a valid directory reaches. +func TestDataIngest_BareCSVFile_Accepted(t *testing.T) { + dir := t.TempDir() + csv := filepath.Join(dir, "churn.csv") + if err := os.WriteFile(csv, []byte("age,churned\n30,yes\n40,no\n"), 0o644); err != nil { + t.Fatalf("write csv: %v", err) + } + code, stdout, _ := execDataIngest(t, []string{ + csv, + "--name=churn", + "--task=tabular_classification", + "--intent=train", + "--label-column=churned", + }) + if code != 3 { + t.Fatalf("expected exit 3 (bare .csv accepted, then bad kubeconfig), got %d", code) + } + if !strings.Contains(stdout, "Inferred schema") { + t.Errorf("want the schema-inference line proving the bare .csv passed the walk; stdout:\n%s", stdout) + } +} + +// TestDataIngest_ImageBareFile_ExitsThree: the image family is directory-only. +// A bare .csv passed as image_classification is rejected at the walk (exit 3) +// and never reaches schema inference (that's tabular-only). +func TestDataIngest_ImageBareFile_ExitsThree(t *testing.T) { + dir := t.TempDir() + csv := filepath.Join(dir, "labels.csv") + if err := os.WriteFile(csv, []byte("image_id,label\n1.jpg,c\n"), 0o644); err != nil { + t.Fatalf("write csv: %v", err) + } + code, stdout, _ := execDataIngest(t, []string{ + csv, + "--name=imgs", + "--task=image_classification", + "--intent=train", + "--label-column=label", + }) + if code != 3 { + t.Fatalf("expected exit 3 for a bare file passed as image, got %d", code) + } + if strings.Contains(stdout, "Inferred schema") { + t.Errorf("image walk must not run tabular schema inference on a bare file; stdout:\n%s", stdout) + } +} + +// TestDataIngestCmd_UsesDatasetArgName: the positional arg is +// (renamed from , #181) in the command's Use string and help. +func TestDataIngestCmd_UsesDatasetArgName(t *testing.T) { + cmd := newDataIngestCmd() + if !strings.Contains(cmd.Use, "") { + t.Errorf("Use = %q, want it to name the arg ", cmd.Use) + } + if strings.Contains(cmd.Use, "") { + t.Errorf("Use = %q still uses the old name", cmd.Use) + } +} + // TestDataIngest_MissingLabelsCSV_ExitsThree: most likely "real // world" wrong-layout case — customer has images but forgot // labels.csv. Pins the exit-code contract for the common failure diff --git a/internal/cli/interactive.go b/internal/cli/interactive.go index 5e8eec20..aa085916 100644 --- a/internal/cli/interactive.go +++ b/internal/cli/interactive.go @@ -136,8 +136,8 @@ func runInteractive(p *ui.Printer, pr prompter, a *runDataIngestArgs, taskSet bo // (c) path — then detect the family from the layout and echo it back. if a.LocalPath == "" { - p.PromptHint("The folder holding your data — a single .csv for a table, or labels.csv + an images/ folder for images. e.g. ~/datasets/churn") - ans, err := pr.Input("Where is your data? (the folder holding it)", "e.g. ./my-data", "", validateDatasetPath) + p.PromptHint("The file or folder holding your data — a single .csv for a table, or labels.csv + an images/ folder for images. e.g. ~/datasets/churn") + ans, err := pr.Input("Where is your data? (file or folder)", "e.g. ./my-data", "", validateDatasetPath) if err != nil { return err } @@ -153,6 +153,16 @@ func runInteractive(p *ui.Printer, pr prompter, a *runDataIngestArgs, taskSet bo // the real path; runDataIngest's own expandHome then no-ops. a.LocalPath = expandHome(a.LocalPath) + // Path existence FIRST (#181): fail plainly on a typo'd path here, before + // the family sniff / label preview below touch it — otherwise the user + // answers the whole questionnaire (family, task, label) against a path + // that doesn't exist, only to hit the hard error afterward. runDataIngest + // re-checks for the flag-only route; this keeps the invariant on the + // guided route too. The exitError propagates unwrapped (see runDataIngest). + if err := statDatasetPath(a.LocalPath); err != nil { + return err + } + // (d) task — family-scoped. An explicit --task wins and skips both the // sniff and the picker (§5.1). Otherwise the family is sniffed from the // layout (and echoed), or asked plainly when the layout is ambiguous, diff --git a/internal/cli/interactive_test.go b/internal/cli/interactive_test.go index 529ed740..c15e35a0 100644 --- a/internal/cli/interactive_test.go +++ b/internal/cli/interactive_test.go @@ -103,11 +103,11 @@ func textDirLayout(t *testing.T) string { func TestRunInteractive_PromptOrder(t *testing.T) { dir := tabularDir(t) f := &fakePrompter{answers: map[string]string{ - "Is this training or test data?": "test", - "What should we call this dataset?": "churn_train", - "Where is your data? (the folder holding it)": dir, - "Which task?": "Tabular classification", - "Which column holds the class?": "churned", + "Is this training or test data?": "test", + "What should we call this dataset?": "churn_train", + "Where is your data? (file or folder)": dir, + "Which task?": "Tabular classification", + "Which column holds the class?": "churned", }} a := &runDataIngestArgs{} if err := runInteractive(discardPrinter(), f, a, false /*taskSet*/); err != nil { @@ -119,7 +119,7 @@ func TestRunInteractive_PromptOrder(t *testing.T) { want := []string{ "Is this training or test data?", "What should we call this dataset?", - "Where is your data? (the folder holding it)", + "Where is your data? (file or folder)", "Which task?", "Which column holds the class?", } @@ -133,6 +133,36 @@ func TestRunInteractive_PromptOrder(t *testing.T) { } } +// TestRunInteractive_PathPromptCopyIsFileOrFolder pins the #181 copy +// restoration: now that the walk accepts a bare .csv, the path prompt says +// "file or folder" again (softened to folder-only in #180b). +func TestRunInteractive_PathPromptCopyIsFileOrFolder(t *testing.T) { + dir := tabularDir(t) + f := &fakePrompter{answers: map[string]string{ + "Is this training or test data?": "train", + "What should we call this dataset?": "churn", + "Where is your data? (file or folder)": dir, + "Which task?": "Tabular classification", + "Which column holds the class?": "churned", + }} + a := &runDataIngestArgs{} + if err := runInteractive(discardPrinter(), f, a, false /*taskSet*/); err != nil { + t.Fatalf("runInteractive: %v", err) + } + found := false + for _, label := range f.asked { + if label == "Where is your data? (file or folder)" { + found = true + } + if strings.Contains(label, "the folder holding it") { + t.Errorf("path prompt still uses the folder-only copy: %q", label) + } + } + if !found { + t.Errorf("path prompt label not asked; got %v", f.asked) + } +} + // TestRunInteractive_SniffEchoesFamily: a confident layout is echoed back // and the family question is NOT asked (the sniff is enough). func TestRunInteractive_SniffEchoesFamily(t *testing.T) { @@ -452,8 +482,8 @@ func TestRunInteractive_RejectsBadName(t *testing.T) { // directory (empty path → Abs("") → cwd). func TestRunInteractive_RejectsEmptyPath(t *testing.T) { f := &fakePrompter{answers: map[string]string{ - "What should we call this dataset?": "t", - "Where is your data? (the folder holding it)": " ", + "What should we call this dataset?": "t", + "Where is your data? (file or folder)": " ", }} a := &runDataIngestArgs{Spec: push.SpecArgs{Intent: "train"}} if err := runInteractive(discardPrinter(), f, a, false); err == nil { @@ -469,9 +499,9 @@ func TestRunInteractive_RejectsEmptyPath(t *testing.T) { func TestRunInteractive_TrimsPath(t *testing.T) { dir := tabularDir(t) f := &fakePrompter{answers: map[string]string{ - "What should we call this dataset?": "t", - "Where is your data? (the folder holding it)": " " + dir + " ", - "Which column holds the class?": "churned", + "What should we call this dataset?": "t", + "Where is your data? (file or folder)": " " + dir + " ", + "Which column holds the class?": "churned", }} a := &runDataIngestArgs{Spec: push.SpecArgs{Intent: "train"}} if err := runInteractive(discardPrinter(), f, a, false); err != nil { diff --git a/internal/cluster/kubeconfig.go b/internal/cluster/kubeconfig.go index 2a943806..4185a232 100644 --- a/internal/cluster/kubeconfig.go +++ b/internal/cluster/kubeconfig.go @@ -12,12 +12,12 @@ package cluster import ( "fmt" - "os" - "path/filepath" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" + + "github.com/tracebloc/cli/internal/pathutil" ) // KubeconfigOptions captures every knob the customer can turn when @@ -142,23 +142,13 @@ func NewClientset(rc *ResolvedConfig) (kubernetes.Interface, error) { } // expandPath handles `~/.kube/config` → `/home/user/.kube/config` -// since clientcmd's ExplicitPath wants an absolute path. Empty -// strings pass through unchanged (they signal "use defaults" to -// clientcmd). +// (and `~alice/.kube/config` → alice's home) since clientcmd's +// ExplicitPath wants an absolute path. Empty strings pass through +// unchanged (they signal "use defaults" to clientcmd). It delegates to +// the shared pathutil.ExpandHome so ~-expansion is identical to the +// data-ingest path helper — same ~user syntax, same result, every +// subcommand. An unresolvable home is returned unexpanded so clientcmd's +// own "tried to read ~/.kube/config and got X" error still surfaces. func expandPath(p string) string { - if p == "" { - return "" - } - if p[0] != '~' { - return p - } - home, err := os.UserHomeDir() - if err != nil { - // Best-effort: return the unexpanded path and let - // clientcmd's error surface mention it. Failing here would - // hide the more useful "tried to read ~/.kube/config and - // got X" error downstream. - return p - } - return filepath.Join(home, p[1:]) + return pathutil.ExpandHome(p) } diff --git a/internal/pathutil/expand.go b/internal/pathutil/expand.go new file mode 100644 index 00000000..5ccb42a5 --- /dev/null +++ b/internal/pathutil/expand.go @@ -0,0 +1,55 @@ +// Package pathutil holds small filesystem-path helpers shared across +// the CLI. It's a leaf package (no internal imports) so both +// internal/cli and internal/cluster can use it without importing each +// other — the consolidation the two former copies of this expander +// kept promising in their doc comments. +package pathutil + +import ( + "os" + "os/user" + "path/filepath" + "strings" +) + +// ExpandHome expands a leading ~ to a home directory, leaving every +// other path (relative, absolute, empty) untouched: +// +// - "" → "" (callers read empty as "use defaults") +// - "~" and "~/…" → the current user's $HOME +// - "~user" and "~user/…" → that named user's home (via user.Lookup) +// +// When a home can't be resolved the literal path is returned unchanged, +// so the caller's own path-existence check reports it plainly ("no such +// file or directory: ~bob/data") instead of us silently mangling it into +// a relative path. That covers three cases: +// +// - no $HOME for the current user, +// - an unknown or unlookupable ~user (a static CGO-less binary can't +// read /etc/passwd for a foreign user), and +// - a resolvable account whose passwd home-directory field is blank — +// user.Lookup succeeds with an empty HomeDir, and joining "" would +// yield a relative path, so we treat it like a resolution failure. +func ExpandHome(path string) string { + if path == "" || path[0] != '~' { + return path + } + // "~" or "~/…" → the current user's home. path[1:] is "" for "~" + // (→ home) and "/x" for "~/x" (→ home/x); filepath.Join cleans it. + if len(path) == 1 || path[1] == '/' { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return path + } + return filepath.Join(home, path[1:]) + } + // "~user" or "~user/…" → the named user's home. Split the username + // off at the first slash; the remainder (possibly empty) joins onto + // their home directory. + name, rest, _ := strings.Cut(path[1:], "/") + u, err := user.Lookup(name) + if err != nil || u.HomeDir == "" { + return path + } + return filepath.Join(u.HomeDir, rest) +} diff --git a/internal/pathutil/expand_test.go b/internal/pathutil/expand_test.go new file mode 100644 index 00000000..793f4c80 --- /dev/null +++ b/internal/pathutil/expand_test.go @@ -0,0 +1,69 @@ +package pathutil + +import ( + "os" + "os/user" + "path/filepath" + "testing" +) + +// TestExpandHome_Basics pins the non-lookup contract: empty, relative, +// absolute, and non-tilde paths pass through untouched; "~" and "~/…" +// resolve under the current user's $HOME. +func TestExpandHome_Basics(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil || home == "" { + t.Skipf("no home dir: %v", err) + } + cases := []struct{ in, want string }{ + {"", ""}, + {"relative/path", "relative/path"}, + {"/absolute/path", "/absolute/path"}, + {"./x", "./x"}, + {"~", home}, + {"~/", home}, + {"~/x", filepath.Join(home, "x")}, + {"~/a/b", filepath.Join(home, "a", "b")}, + } + for _, c := range cases { + if got := ExpandHome(c.in); got != c.want { + t.Errorf("ExpandHome(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// TestExpandHome_NamedUser covers the ~user form (#181): "~user" and +// "~user/…" resolve under that user's home. We look up the CURRENT user by +// name so the test doesn't depend on a fixed account, and an unknown ~user +// is left literal so the caller's path-existence check surfaces it. +func TestExpandHome_NamedUser(t *testing.T) { + u, err := user.Current() + if err != nil || u.Username == "" { + t.Skipf("no current user: %v", err) + } + looked, err := user.Lookup(u.Username) + if err != nil { + t.Skipf("user.Lookup(%q) unsupported here: %v", u.Username, err) + } + if looked.HomeDir == "" { + t.Skipf("current user has a blank home dir") + } + home := looked.HomeDir + + cases := []struct{ in, want string }{ + {"~" + u.Username, home}, + {"~" + u.Username + "/data", filepath.Join(home, "data")}, + {"~" + u.Username + "/a/b", filepath.Join(home, "a", "b")}, + } + for _, c := range cases { + if got := ExpandHome(c.in); got != c.want { + t.Errorf("ExpandHome(%q) = %q, want %q", c.in, got, c.want) + } + } + + // An unknown user can't be resolved: the literal is returned unchanged. + const unknown = "~nsuchuser-tracebloc-181/x" + if got := ExpandHome(unknown); got != unknown { + t.Errorf("ExpandHome(%q) = %q, want it left literal", unknown, got) + } +} diff --git a/internal/push/preview.go b/internal/push/preview.go index 39a9da5a..f1248524 100644 --- a/internal/push/preview.go +++ b/internal/push/preview.go @@ -30,9 +30,9 @@ type FamilySniff struct { // SniffFamily previews the family of the dataset at path by looking for // the same layout markers Discover / DiscoverText / DiscoverTabular key // on — labels.csv + an images/ dir (image), labels.csv + a texts/ or -// sequences/ dir (text), or exactly one CSV in a directory with none of -// those (tabular). It reads directory entries only; it opens no files and -// validates nothing. +// sequences/ dir (text), exactly one CSV in a directory with none of +// those (tabular), or a bare .csv file (tabular). It reads directory +// entries only; it opens no files and validates nothing. // // It never claims more than the matching Discover* would accept: the // marker directories (images/, texts/, sequences/) and labels.csv are @@ -40,18 +40,20 @@ type FamilySniff struct { // Lstats — a mis-cased "Images/" is not the walk's marker, so it is not // sniffed as confident image. Image / text are confident only when BOTH // labels.csv AND the subdir are present, mirroring Discover / DiscoverText. -// Tabular is confident only on EXACTLY ONE CSV, mirroring DiscoverTabular's -// findSingleCSV count rule — a directory with two or more CSVs is a layout -// the tabular walk refuses, so the sniff must not confidently place it -// either. Only the .csv extension match stays case-insensitive, mirroring +// Tabular is confident on EXACTLY ONE CSV in a directory, mirroring +// DiscoverTabular's findSingleCSV count rule — a directory with two or more +// CSVs is a layout the tabular walk refuses, so the sniff must not +// confidently place it either — OR on a bare .csv file, which +// DiscoverTabular now stages as the one CSV under the dataset (#181). Only +// the .csv extension match stays case-insensitive, mirroring // DiscoverTabular's EqualFold. // -// Every family's walk requires a directory (bare-file support is -// cli#181), so a file path is never a confident sniff. Anything we can't -// place — a missing path, a bare file, a directory with no recognizable -// marker, an image+text mix, an image/text dir without labels.csv, a -// multi-CSV directory the tabular walk would reject — comes back -// Confident=false so the caller asks the family plainly. +// The media/label families (image, text) require a directory, so a bare +// file that is not a .csv is never a confident sniff. Anything we can't +// place — a missing path, a non-.csv bare file, a directory with no +// recognizable marker, an image+text mix, an image/text dir without +// labels.csv, a multi-CSV directory the tabular walk would reject — comes +// back Confident=false so the caller asks the family plainly. func SniffFamily(path string) FamilySniff { abs, err := filepath.Abs(path) if err != nil { @@ -64,11 +66,22 @@ func SniffFamily(path string) FamilySniff { return FamilySniff{} } - // Every family's walk requires a directory; a bare file (even a .csv) - // is rejected by DiscoverTabular until cli#181 adds bare-file support. - // Stay ambiguous so the caller asks the family plainly rather than - // promising a layout the walk would refuse. + // A bare file: only a .csv is placeable (tabular), mirroring the + // bare-file shape DiscoverTabular accepts (#181). The image / text + // families need a directory, so any other bare file stays ambiguous. + // + // Lstat first: os.Stat above followed a symlink, but DiscoverTabular + // stats the CSV with Lstat and rejectSymlinks it — so a symlinked .csv + // is a layout the walk REFUSES. Sniffing it as confident tabular would + // break this func's "never claims more than the matching Discover* would + // accept" contract (it'd lock the guided flow to tabular, then hard-fail + // on the symlink guard). Treat a symlink like any other unplaceable file. if !st.IsDir() { + if li, lerr := os.Lstat(abs); lerr == nil && + li.Mode()&os.ModeSymlink == 0 && isCSV(abs) { + return FamilySniff{Family: FamilyTabular, Confident: true, + Echo: "Found a CSV table — this is tabular data."} + } return FamilySniff{} } @@ -110,7 +123,7 @@ func SniffFamily(path string) FamilySniff { if name == "labels.csv" { hasLabels = true } - if strings.EqualFold(filepath.Ext(name), ".csv") { + if isCSV(name) { csvCount++ } } diff --git a/internal/push/preview_test.go b/internal/push/preview_test.go index 17b72f4f..dba65827 100644 --- a/internal/push/preview_test.go +++ b/internal/push/preview_test.go @@ -44,15 +44,50 @@ func TestSniffFamily(t *testing.T) { } }) - t.Run("bare .csv file is ambiguous (walk requires a directory)", func(t *testing.T) { - // DiscoverTabular rejects a bare file (bare-file support is cli#181), - // so the sniff must not confidently place a lone .csv — otherwise it - // promises a layout the walk refuses. + t.Run("bare .csv file is confident tabular (walk now accepts it)", func(t *testing.T) { + // DiscoverTabular now stages a bare .csv as the one CSV under the + // dataset (cli#181), so the sniff confidently places a lone .csv as + // tabular — mirroring the shape the walk accepts. dir := t.TempDir() csv := filepath.Join(dir, "t.csv") writePrev(t, csv, "a,b\n1,2\n") - if s := SniffFamily(csv); s.Confident { - t.Fatalf("a bare .csv file should be ambiguous, got %+v", s) + s := SniffFamily(csv) + if !s.Confident || s.Family != FamilyTabular { + t.Fatalf("a bare .csv file should sniff confident tabular, got %+v", s) + } + // And the walk it mirrors accepts the same bare file. + if _, err := DiscoverTabular(csv); err != nil { + t.Fatalf("DiscoverTabular should accept a bare .csv: %v", err) + } + }) + + t.Run("bare non-.csv file is ambiguous (media families need a folder)", func(t *testing.T) { + dir := t.TempDir() + txt := filepath.Join(dir, "notes.txt") + writePrev(t, txt, "hello") + if s := SniffFamily(txt); s.Confident { + t.Fatalf("a bare non-.csv file should be ambiguous, got %+v", s) + } + }) + + t.Run("symlinked .csv is ambiguous, matching the walk's symlink rejection", func(t *testing.T) { + // DiscoverTabular rejects a symlinked CSV (rejectSymlink), so the + // sniff must not confidently promise tabular for one — otherwise the + // guided flow locks to tabular, then hard-fails on the walk. Sniff and + // walk must agree: both refuse. (cli#202 review) + dir := t.TempDir() + real := filepath.Join(dir, "real.csv") + writePrev(t, real, "a,b\n1,2\n") + link := filepath.Join(dir, "link.csv") + if err := os.Symlink(real, link); err != nil { + t.Skipf("symlink unsupported on this platform: %v", err) + } + if s := SniffFamily(link); s.Confident { + t.Fatalf("a symlinked .csv should be ambiguous (walk rejects it), got %+v", s) + } + // And the walk it mirrors does reject the same symlinked file. + if _, err := DiscoverTabular(link); err == nil { + t.Fatalf("DiscoverTabular should reject a symlinked .csv") } }) diff --git a/internal/push/tabular.go b/internal/push/tabular.go index ecc14589..ac0256b2 100644 --- a/internal/push/tabular.go +++ b/internal/push/tabular.go @@ -39,15 +39,30 @@ var reservedColumns = map[string]bool{ // turns float on row 10k) is the case --schema exists to override. const schemaInferenceSampleRows = 5000 -// DiscoverTabular validates a local directory for a tabular / -// time-series ingestion. Unlike the image layout, tabular categories -// have NO sidecar files — the dataset IS a single CSV. The directory -// must contain exactly one .csv file; that becomes the labels/data -// CSV staged for the ingestor. +// DiscoverTabular validates a local input for a tabular / time-series +// ingestion. Unlike the image layout, tabular categories have NO +// sidecar files — the dataset IS a single CSV. Two shapes are accepted +// (#181): +// +// - a bare .csv file: the dataset itself, passed directly; +// - a directory containing exactly one .csv file. +// +// Both resolve to the SAME staged layout — the CSV is staged as the one +// labels.csv under the dataset — so the ingestor's contract is unchanged +// (this is a CLI-side input convenience, not an ingestor-side change). // // The returned LocalLayout reuses the image layout's LabelsCSV field // (staged as labels.csv) with an empty Images slice, so the existing // tar/stream machinery handles it unchanged. +// isCSV reports whether name has a .csv extension, matched +// case-insensitively. It's the single rule DiscoverTabular's walk, its +// bare-file branch, and SniffFamily all key on — shared so the sniff's +// "confident tabular" promise can never drift from what the walk actually +// accepts (the exact lockstep the surrounding comments rely on). +func isCSV(name string) bool { + return strings.EqualFold(filepath.Ext(name), ".csv") +} + // findSingleCSV resolves the one .csv file a tabular layout must hold in // dir, enforcing DiscoverTabular's exactly-one rule: zero or multiple CSVs // are errors with the same framing. dir must already be known to be a @@ -64,7 +79,7 @@ func findSingleCSV(dir string) (string, error) { if e.IsDir() { continue } - if strings.EqualFold(filepath.Ext(e.Name()), ".csv") { + if isCSV(e.Name()) { csvs = append(csvs, e.Name()) } } @@ -92,16 +107,31 @@ func DiscoverTabular(rootDir string) (*LocalLayout, error) { } st, err := os.Stat(abs) if err != nil { - return nil, fmt.Errorf("reading dataset directory %q: %w", abs, err) - } - if !st.IsDir() { - return nil, fmt.Errorf( - "%q is not a directory; pass the directory containing the dataset CSV", abs) + return nil, fmt.Errorf("reading dataset path %q: %w", abs, err) } - csvPath, err := findSingleCSV(abs) - if err != nil { - return nil, err + // Resolve the CSV + the layout root from either shape. A directory + // takes DiscoverTabular's exactly-one-CSV rule (findSingleCSV); a bare + // file is accepted only when it's a .csv — the dataset IS that CSV, so + // it stages identically to a one-CSV directory (#181). The root is the + // directory either way (the file's parent for the bare-file case), so + // the pre-flight summary's "root" field stays a directory. + var csvPath, root string + if st.IsDir() { + root = abs + csvPath, err = findSingleCSV(abs) + if err != nil { + return nil, err + } + } else { + if !isCSV(abs) { + return nil, fmt.Errorf( + "%q is not a .csv file. Tabular / time-series data is a single CSV — "+ + "pass the .csv file itself, or a directory containing exactly one .csv.", + abs) + } + root = filepath.Dir(abs) + csvPath = abs } csvName := filepath.Base(csvPath) // Lstat (not Stat) so a symlinked CSV is rejected rather than @@ -117,7 +147,7 @@ func DiscoverTabular(rootDir string) (*LocalLayout, error) { return nil, sizeError(csvName, info.Size(), MaxSingleFileBytes) } - layout := &LocalLayout{Root: abs, LabelsCSV: csvPath, TotalBytes: info.Size()} + layout := &LocalLayout{Root: root, LabelsCSV: csvPath, TotalBytes: info.Size()} if layout.TotalBytes > MaxTotalBytes { return nil, fmt.Errorf( "dataset is %s, exceeds v0.1 cap of %s. For larger datasets, the "+ diff --git a/internal/push/tabular_test.go b/internal/push/tabular_test.go index 28c6d026..d47d920e 100644 --- a/internal/push/tabular_test.go +++ b/internal/push/tabular_test.go @@ -1,6 +1,8 @@ package push import ( + "archive/tar" + "bytes" "os" "path/filepath" "testing" @@ -38,6 +40,86 @@ func TestDiscoverTabular_SingleCSV(t *testing.T) { } } +// TestDiscoverTabular_BareCSVFile: a bare .csv file (not a directory) is +// accepted for tabular (#181). It resolves to a layout whose LabelsCSV is +// that file, Root is the file's parent directory, and Images is empty — the +// SAME shape a single-CSV directory produces, so the tar/stream machinery +// stages it identically (as labels.csv under the dataset). +func TestDiscoverTabular_BareCSVFile(t *testing.T) { + dir := t.TempDir() + csv := writeFile(t, dir, "churn.csv", "age,churned\n30,yes\n40,no\n") + + layout, err := DiscoverTabular(csv) + if err != nil { + t.Fatalf("DiscoverTabular(bare .csv): %v", err) + } + if layout.LabelsCSV != csv { + t.Errorf("LabelsCSV = %q, want %q", layout.LabelsCSV, csv) + } + if layout.Root != dir { + t.Errorf("Root = %q, want the file's parent dir %q", layout.Root, dir) + } + if len(layout.Images) != 0 { + t.Errorf("Images = %v, want empty", layout.Images) + } + if layout.TotalBytes == 0 { + t.Errorf("TotalBytes = 0, want the CSV's size") + } +} + +// TestDiscoverTabular_BareFileVsDirSameStaging: a bare .csv and a directory +// holding that same CSV must stage byte-for-identically — both land the CSV +// as labels.csv at the dataset root — so bare-file support is a pure CLI-side +// input convenience and never changes what the ingestor reads. +func TestDiscoverTabular_BareFileVsDirSameStaging(t *testing.T) { + body := "age,churned\n30,yes\n40,no\n" + fileDir := t.TempDir() + bare := writeFile(t, fileDir, "churn.csv", body) + // Directory holding the same single CSV. + someDir := t.TempDir() + writeFile(t, someDir, "churn.csv", body) + + fileL, err := DiscoverTabular(bare) + if err != nil { + t.Fatalf("DiscoverTabular(file): %v", err) + } + dirL, err := DiscoverTabular(someDir) + if err != nil { + t.Fatalf("DiscoverTabular(dir): %v", err) + } + + var fileTar, dirTar bytes.Buffer + if err := writeLayoutTar(&fileTar, fileL); err != nil { + t.Fatalf("writeLayoutTar(file): %v", err) + } + if err := writeLayoutTar(&dirTar, dirL); err != nil { + t.Fatalf("writeLayoutTar(dir): %v", err) + } + if !bytes.Equal(fileTar.Bytes(), dirTar.Bytes()) { + t.Error("bare-file and single-CSV-dir produced different staged tars; they must be identical") + } + // And the one entry is labels.csv. + tr := tar.NewReader(&fileTar) + hdr, err := tr.Next() + if err != nil { + t.Fatalf("reading tar entry: %v", err) + } + if hdr.Name != "labels.csv" { + t.Errorf("staged entry = %q, want labels.csv", hdr.Name) + } +} + +// TestDiscoverTabular_BareNonCSVFile: a bare file that isn't a .csv is a +// clear error — tabular data is a single CSV, so we say so rather than +// letting a downstream reader choke. +func TestDiscoverTabular_BareNonCSVFile(t *testing.T) { + dir := t.TempDir() + txt := writeFile(t, dir, "notes.txt", "hello") + if _, err := DiscoverTabular(txt); err == nil { + t.Error("DiscoverTabular(bare .txt) returned nil error, want a clear .csv-required error") + } +} + // TestDiscoverTabular_NoCSV and _MultipleCSV: the layout requires // exactly one CSV; zero or many is a clear, actionable error rather // than a guess. diff --git a/internal/push/text_test.go b/internal/push/text_test.go index 472bb450..9556ce62 100644 --- a/internal/push/text_test.go +++ b/internal/push/text_test.go @@ -3,6 +3,7 @@ package push import ( "os" "path/filepath" + "strings" "testing" "gopkg.in/yaml.v3" @@ -34,6 +35,21 @@ func mkTextDir(t *testing.T, sidecar string, withTokenizer bool) string { return dir } +// TestDiscoverText_BareFileRejected: the text layout is directory-only. +// A bare file (even a .csv) must be rejected with a clear "not a directory" +// error — bare-file support is tabular-only (#181). +func TestDiscoverText_BareFileRejected(t *testing.T) { + dir := t.TempDir() + bare := writeFile(t, dir, "labels.csv", "filename,label\na.txt,pos\n") + _, err := DiscoverText("text_classification", bare) + if err == nil { + t.Fatal("DiscoverText(bare file) returned nil error; text layout must require a directory") + } + if !strings.Contains(err.Error(), "not a directory") { + t.Errorf("error = %q, want it to say the path is not a directory", err) + } +} + // TestDiscoverText_Classification: text_classification stages // labels.csv + the texts/ directory, no images, no extra files. func TestDiscoverText_Classification(t *testing.T) { diff --git a/internal/push/walk_test.go b/internal/push/walk_test.go index 114cbe8f..e77cf8e2 100644 --- a/internal/push/walk_test.go +++ b/internal/push/walk_test.go @@ -113,6 +113,24 @@ func TestDiscover_SkipsNonImageFiles(t *testing.T) { } } +// TestDiscover_BareFileRejected: the image layout is directory-only. A bare +// file (even a .csv) must be rejected with a clear "not a directory" error — +// bare-file support is tabular-only (#181), so image datasets can't shortcut it. +func TestDiscover_BareFileRejected(t *testing.T) { + dir := t.TempDir() + bare := filepath.Join(dir, "labels.csv") + if err := os.WriteFile(bare, []byte("image_id,label\n1.jpg,c\n"), 0o644); err != nil { + t.Fatal(err) + } + _, err := Discover(bare) + if err == nil { + t.Fatal("Discover(bare file) returned nil error; image layout must require a directory") + } + if !strings.Contains(err.Error(), "not a directory") { + t.Errorf("error = %q, want it to say the path is not a directory", err) + } +} + func TestDiscover_MissingLabelsCSV(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, "images"), 0o755); err != nil { From 2db12f96743a4114a98e4a9bf5359e4fbd7ed3b1 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:41:30 +0200 Subject: [PATCH 07/22] feat(data ingest): expected image size + local min-size floor preview (#183) (#206) Image expected-size reframe + local min-size floor preview (gated on the deployed ingestor floor), plus Asad's image-only-flag guard: --target-size/--min-size now hard-error (exit 2) on non-image tasks. Reviewed and approved by @saadqbal, who also authored the flag-guard commits. Closes #183. Co-authored-by: saadqbal Co-authored-by: Claude Opus 4.8 --- internal/cli/coverage_test.go | 43 ++++++++ internal/cli/data.go | 45 +++++++- internal/cli/interactive.go | 12 ++- internal/push/detect.go | 23 +++- internal/push/parity_golden_test.go | 2 + internal/push/preflight.go | 58 ++++++++-- internal/push/preflight_test.go | 59 +++++++++- internal/push/spec.go | 37 +++++++ internal/push/spec_test.go | 59 ++++++++++ internal/push/testdata/parity/cases.json | 101 ++++++++++++------ .../parity/cases/imgc-bom-labels/images/a.jpg | Bin 633 -> 644 bytes .../parity/cases/imgc-bom-labels/images/b.jpg | Bin 633 -> 644 bytes .../parity/cases/imgc-corrupt/images/a.jpg | Bin 633 -> 644 bytes .../cases/imgc-dotted-stem/images/b.jpg | Bin 632 -> 644 bytes .../imgc-dotted-stem/images/photo.2024.jpg | Bin 632 -> 644 bytes .../parity/cases/imgc-dup-header/images/a.jpg | Bin 632 -> 644 bytes .../parity/cases/imgc-dup-header/images/b.jpg | Bin 632 -> 644 bytes .../cases/imgc-empty-label/images/a.jpg | Bin 632 -> 644 bytes .../cases/imgc-empty-label/images/b.jpg | Bin 632 -> 644 bytes .../cases/imgc-header-only/images/a.jpg | Bin 633 -> 644 bytes .../parity/cases/imgc-label-case/images/a.jpg | Bin 633 -> 644 bytes .../parity/cases/imgc-label-case/images/b.jpg | Bin 633 -> 644 bytes .../cases/imgc-label-missing/images/a.jpg | Bin 633 -> 644 bytes .../cases/imgc-label-uniform/images/a.jpg | Bin 633 -> 644 bytes .../cases/imgc-label-uniform/images/b.jpg | Bin 633 -> 644 bytes .../cases/imgc-min-size-override/images/a.jpg | Bin 0 -> 653 bytes .../cases/imgc-min-size-override/images/b.jpg | Bin 0 -> 653 bytes .../cases/imgc-min-size-override/labels.csv | 3 + .../cases/imgc-missing-file/images/a.jpg | Bin 633 -> 644 bytes .../cases/imgc-nonsquare-swapped/images/a.jpg | Bin 632 -> 660 bytes .../cases/imgc-nonsquare-swapped/images/b.jpg | Bin 632 -> 660 bytes .../parity/cases/imgc-nonsquare/images/a.jpg | Bin 632 -> 660 bytes .../parity/cases/imgc-nonsquare/images/b.jpg | Bin 632 -> 660 bytes .../parity/cases/imgc-ok/images/a.jpg | Bin 633 -> 644 bytes .../parity/cases/imgc-ok/images/b.jpg | Bin 633 -> 644 bytes .../cases/imgc-res-mismatch/images/a.jpg | Bin 633 -> 644 bytes .../cases/imgc-res-mismatch/images/odd.jpg | Bin 633 -> 664 bytes .../parity/cases/imgc-too-small/images/a.jpg | Bin 0 -> 632 bytes .../parity/cases/imgc-too-small/images/b.jpg | Bin 0 -> 632 bytes .../parity/cases/imgc-too-small/labels.csv | 3 + .../parity/cases/imgc-zero-byte/images/a.jpg | Bin 633 -> 644 bytes internal/push/testdata/parity/goldens.json | 32 ++++-- internal/schema/ingest.v1.json | 7 ++ scripts/.data-ingestors-ref | 2 +- scripts/gen-validator-goldens.py | 6 ++ 45 files changed, 427 insertions(+), 65 deletions(-) create mode 100644 internal/push/testdata/parity/cases/imgc-min-size-override/images/a.jpg create mode 100644 internal/push/testdata/parity/cases/imgc-min-size-override/images/b.jpg create mode 100644 internal/push/testdata/parity/cases/imgc-min-size-override/labels.csv create mode 100644 internal/push/testdata/parity/cases/imgc-too-small/images/a.jpg create mode 100644 internal/push/testdata/parity/cases/imgc-too-small/images/b.jpg create mode 100644 internal/push/testdata/parity/cases/imgc-too-small/labels.csv diff --git a/internal/cli/coverage_test.go b/internal/cli/coverage_test.go index c176166b..f07e4612 100644 --- a/internal/cli/coverage_test.go +++ b/internal/cli/coverage_test.go @@ -198,6 +198,49 @@ func TestRunDatasetPush_OutputJSONEarlyFailureEmitsJSON(t *testing.T) { } } +// TestRunDataIngest_ImageOnlyFlagsRejectedOnNonImage: --target-size and +// --min-size describe image resolution, so on a tabular/text task they +// must fail fast (exit 2) with a clear message rather than being parsed +// only inside the image branch — where the value, even a malformed one, +// was silently dropped (#206 review). +func TestRunDataIngest_ImageOnlyFlagsRejectedOnNonImage(t *testing.T) { + cases := []struct { + name string + mutate func(*runDataIngestArgs) + }{ + {"target-size on tabular", func(a *runDataIngestArgs) { a.TargetSizeFlag = "64x64" }}, + {"min-size on tabular", func(a *runDataIngestArgs) { a.MinSizeFlag = "32x32" }}, + {"malformed min-size on tabular", func(a *runDataIngestArgs) { a.MinSizeFlag = "garbage" }}, + } + // A real, existing path so the earlier dataset-path stat passes and + // the image-only guard is what actually fires (the path check runs + // before the guard). + dir := t.TempDir() + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var human bytes.Buffer + a := runDataIngestArgs{ + LocalPath: dir, + Spec: push.SpecArgs{ + Table: "t", Category: "tabular_classification", + Intent: "train", LabelColumn: "y", + }, + Printer: ui.New(&human, ui.WithColor(false)), + } + c.mutate(&a) + err := runDataIngest(context.Background(), &human, &human, a) + + var ee *exitError + if !errors.As(err, &ee) || ee.Code() != 2 { + t.Fatalf("err = %v, want *exitError code 2", err) + } + if !strings.Contains(ee.Error(), "image tasks only") { + t.Errorf("error should explain the flag is image-only; got: %v", ee) + } + }) + } +} + // TestExpandHome covers the #37 fix: a leading ~ / ~/… resolves under // $HOME, while relative, absolute, and empty paths pass through // untouched (the case that bit the interactive prompt — the shell diff --git a/internal/cli/data.go b/internal/cli/data.go index d52742b8..74790d2e 100644 --- a/internal/cli/data.go +++ b/internal/cli/data.go @@ -109,6 +109,7 @@ func newDataIngestCmd() *cobra.Command { intent string labelColumn string targetSize string + minSize string schemaFlag string labelPolicy string timeColumn string @@ -259,6 +260,7 @@ Exit codes: NumberOfKeypoints: numberOfKeypoints, }, TargetSizeFlag: targetSize, + MinSizeFlag: minSize, SchemaFlag: schemaFlag, DryRun: dryRun, Overwrite: overwrite, @@ -304,8 +306,13 @@ Exit codes: cmd.Flags().StringVar(&labelColumn, "label-column", "", "name of the label/target column (in labels.csv for image tasks, in the data CSV for tabular)") cmd.Flags().StringVar(&targetSize, "target-size", "", - "image tasks only: resolution as WxH (e.g. 512x512). Default: auto-detected from the first image. "+ - "All images must share this resolution — the ingestor validates it, it does not resize.") + "image tasks only: the resolution your images already are, as WxH (e.g. 512x512). tracebloc never "+ + "resizes — it checks every image is exactly this size and rejects any that differ. Default: "+ + "read from your first image.") + cmd.Flags().StringVar(&minSize, "min-size", "", + "image tasks only: reject images smaller than WxH before the ingest (e.g. 64x64). Set it to the "+ + "smallest size your model can train on — raise or lower it freely. Default: unset (no local "+ + "size check).") cmd.Flags().StringVar(&schemaFlag, "schema", "", "tabular/time-series only: column types as col:TYPE,col:TYPE (e.g. age:INT,price:FLOAT). "+ "Default: inferred from the CSV (INT/FLOAT/VARCHAR).") @@ -354,6 +361,7 @@ type runDataIngestArgs struct { Namespace string Spec push.SpecArgs TargetSizeFlag string // raw --target-size; resolved after Discover (image) + MinSizeFlag string // raw --min-size; resolved after Discover (image) — #348 floor override SchemaFlag string // raw --schema; resolved or inferred after Discover (tabular) DryRun bool Overwrite bool @@ -567,6 +575,24 @@ collaborators can train against that table without ever seeing the raw files.`)) a.Spec.Category, push.SupportedCategoriesList())} } + // Image-only flags. --target-size / --min-size describe image + // resolution, so they're meaningless on a tabular / text task. + // Reject them explicitly here: without this guard they'd be parsed + // only inside the image branch below, so on a non-image task the + // value — even a malformed one — was silently dropped with no error. + if !push.IsImage(a.Spec.Category) { + for _, f := range []struct{ name, val string }{ + {"--target-size", a.TargetSizeFlag}, + {"--min-size", a.MinSizeFlag}, + } { + if f.val != "" { + return &exitError{code: 2, err: fmt.Errorf( + "%s is image tasks only; it doesn't apply to task %q", + f.name, a.Spec.Category)} + } + } + } + // 3. Walk the local directory FIRST (local "fail fast"), dispatched // by category family. Image categories expect labels.csv + // images/; tabular / time-series categories expect a single @@ -673,6 +699,21 @@ collaborators can train against that table without ever seeing the raw files.`)) "resolution mismatch.\n", derr) } } + // Minimum-size floor override (#348): plumb an explicit --min-size to + // spec.file_options.min_size. When unset, no spec field is emitted, so + // the ingestor applies its own default (none on the deployed + // v0.5.7/v0.6.0; 32x32 on develop post-#348) — and the local preview + // applies NO floor either (PreflightDataset only previews the floor + // when --min-size is set, so it never rejects an ingest the live + // cluster accepts). The below-floor reject is previewed in + // runLocalPreflight (ValidateImages). + if a.MinSizeFlag != "" { + w, h, perr := push.ParseMinSize(a.MinSizeFlag) + if perr != nil { + return &exitError{code: 2, err: perr} + } + a.Spec.MinSize = []int{w, h} + } // Extension: every image must share one type, and the spec tells // the cluster which one to validate against (file_options.extension). // Without this the ingestor checked its .jpeg convention default and diff --git a/internal/cli/interactive.go b/internal/cli/interactive.go index aa085916..70c1e279 100644 --- a/internal/cli/interactive.go +++ b/internal/cli/interactive.go @@ -317,9 +317,9 @@ func promptCategorySpecific(p *ui.Printer, pr prompter, a *runDataIngestArgs) (b prompted = true } if a.TargetSizeFlag == "" { - p.PromptHint("All images must share one resolution; the ingestor checks it (it won't resize). Blank = auto-detect from the first image. e.g. 224x224") - ans, err := pr.Input("Image resolution as WxH (blank = auto-detect from the first image)", - "all images must share it; the ingestor validates, it doesn't resize", "", + p.PromptHint("The resolution your images already are. tracebloc never resizes — it checks every image is exactly this size and rejects any that differ. Blank = read it from your first image. e.g. 224x224") + ans, err := pr.Input("Image resolution as WxH (blank = read it from your first image)", + "the size your images already are; tracebloc checks it, it never resizes", "", validateOptionalTargetSize) if err != nil { return prompted, err @@ -412,6 +412,12 @@ func renderReview(p *ui.Printer, a *runDataIngestArgs) { case push.IsImage(a.Spec.Category): p.Field("resolution", "auto-detect") } + // Only shown when set — --min-size is opt-in with no local default, + // so there's nothing to echo otherwise. Surfacing it lets a mistyped + // floor (e.g. 640x640 for 64x64) be caught at the confirm gate. + if a.MinSizeFlag != "" { + p.Field("min size", a.MinSizeFlag) + } switch { case a.SchemaFlag != "": p.Field("schema", a.SchemaFlag) diff --git a/internal/push/detect.go b/internal/push/detect.go index 896d45c6..cdcf5444 100644 --- a/internal/push/detect.go +++ b/internal/push/detect.go @@ -44,6 +44,21 @@ func DetectImageSize(path string) (width, height int, err error) { // height]. Accepts "WxH" (the documented form, e.g. "512x512") and // "W,H" as a convenience. Both dimensions must be positive integers. func ParseTargetSize(s string) (width, height int, err error) { + return parseWxH("target size", s) +} + +// ParseMinSize parses a --min-size flag value into [width, height], +// the same WxH grammar as --target-size (#183). It plumbs to +// spec.file_options.min_size, the ingestor's minimum-image-size floor +// override (data-ingestors #348). +func ParseMinSize(s string) (width, height int, err error) { + return parseWxH("min size", s) +} + +// parseWxH parses a "WxH" (or "W,H") dimension pair, using kind in +// error messages so callers surface "target size …" / "min size …" +// verbatim. Both dimensions must be positive integers. +func parseWxH(kind, s string) (width, height int, err error) { sep := "x" if strings.Contains(s, ",") { sep = "," @@ -51,21 +66,21 @@ func ParseTargetSize(s string) (width, height int, err error) { parts := strings.Split(s, sep) if len(parts) != 2 { return 0, 0, fmt.Errorf( - "target size %q must be WxH (e.g. 512x512)", s) + "%s %q must be WxH (e.g. 512x512)", kind, s) } width, err = strconv.Atoi(strings.TrimSpace(parts[0])) if err != nil { return 0, 0, fmt.Errorf( - "target size %q: width is not an integer: %w", s, err) + "%s %q: width is not an integer: %w", kind, s, err) } height, err = strconv.Atoi(strings.TrimSpace(parts[1])) if err != nil { return 0, 0, fmt.Errorf( - "target size %q: height is not an integer: %w", s, err) + "%s %q: height is not an integer: %w", kind, s, err) } if width <= 0 || height <= 0 { return 0, 0, fmt.Errorf( - "target size %q: width and height must both be positive", s) + "%s %q: width and height must both be positive", kind, s) } return width, height, nil } diff --git a/internal/push/parity_golden_test.go b/internal/push/parity_golden_test.go index 186db758..9fe6efff 100644 --- a/internal/push/parity_golden_test.go +++ b/internal/push/parity_golden_test.go @@ -36,6 +36,7 @@ type parityCase struct { LabelColumn string `json:"label_column"` Extension string `json:"extension"` TargetSize []int `json:"target_size"` + MinSize []int `json:"min_size"` Schema map[string]string `json:"schema"` CLIVerdict string `json:"cli_verdict"` IngestorVerdict string `json:"ingestor_verdict"` @@ -143,6 +144,7 @@ func runGoPreflight(t *testing.T, c parityCase) string { LabelColumn: c.LabelColumn, Extension: c.Extension, TargetSize: c.TargetSize, + MinSize: c.MinSize, } if IsTabular(c.Category) { // Mirror runDataIngest: an explicit schema (the --schema flow) wins, diff --git a/internal/push/preflight.go b/internal/push/preflight.go index a2810eaf..4b884d04 100644 --- a/internal/push/preflight.go +++ b/internal/push/preflight.go @@ -188,18 +188,30 @@ func CheckHasDataRows(path string) error { // ValidateImages previews the ingestor's ImageResolutionValidator // (image_validator.py): it opens EVERY image (header-only decode — cheap) -// and rejects zero-byte files, undecodable files, and any image whose -// resolution differs from the expected size (exact equality, zero -// tolerance — the ingestor validates, it does not resize). Previously the -// CLI decoded only the first image, so a single odd-sized or corrupt file -// failed in-cluster after the full upload (cli#72b/c). +// and rejects zero-byte files, undecodable files, images below the +// minimum-size floor, and any image whose resolution differs from the +// expected size (exact equality, zero tolerance — the ingestor validates, +// it does not resize). Previously the CLI decoded only the first image, so +// a single odd-sized or corrupt file failed in-cluster after the full +// upload (cli#72b/c). // // expectedW/expectedH of 0 skips the resolution comparison (the caller // couldn't establish a target size — the ingestor would then auto-detect // from its first file, which the CLI's detection already mirrors). -func ValidateImages(images []string, expectedW, expectedH int) error { +// +// minW/minH is the minimum-size floor (#348), mirroring the ingestor's +// _meets_min_size: an image is too small when EITHER side is below the +// floor; an image exactly at the floor passes. 0/0 disables the floor. +// PreflightDataset passes a non-zero floor ONLY when the customer set +// --min-size — it does NOT default to MinImageSize, because the deployed +// ingestor has no floor yet (see the PreflightDataset image branch), so a +// default block would reject an ingest the live cluster accepts. The +// too-small check takes precedence over the resolution mismatch, exactly +// as data-ingestors #348 returns the too_small error before the +// target_size uniformity error. +func ValidateImages(images []string, expectedW, expectedH, minW, minH int) error { const maxListed = 5 - var broken, mismatched []string + var broken, tooSmall, mismatched []string for _, path := range images { name := filepath.Base(path) f, err := os.Open(path) @@ -217,11 +229,24 @@ func ValidateImages(images []string, expectedW, expectedH int) error { } continue } + if minW > 0 && minH > 0 && (cfg.Width < minW || cfg.Height < minH) { + tooSmall = append(tooSmall, + fmt.Sprintf("%s (%dx%d)", name, cfg.Width, cfg.Height)) + } if expectedW > 0 && expectedH > 0 && (cfg.Width != expectedW || cfg.Height != expectedH) { mismatched = append(mismatched, fmt.Sprintf("%s (%dx%d)", name, cfg.Width, cfg.Height)) } } + // Floor first: an image below the minimum size simply can't be trained + // on, so it's the most fundamental, actionable failure — data-ingestors + // #348 returns it ahead of the uniformity / target_size mismatch. + if len(tooSmall) > 0 { + return fmt.Errorf( + "%d image(s) are smaller than the %dx%d minimum you set with --min-size: %s. "+ + "Provide larger images, or lower the floor with --min-size, then re-run.", + len(tooSmall), minW, minH, TruncateList(tooSmall, maxListed)) + } if len(broken) > 0 { return fmt.Errorf( "%d image(s) can't be ingested: %s. The cluster rejects these after the upload — "+ @@ -688,7 +713,24 @@ func PreflightDataset(spec SpecArgs, layout *LocalLayout) (notes []string, probl if len(spec.TargetSize) == 2 { expW, expH = spec.TargetSize[0], spec.TargetSize[1] } - if err := ValidateImages(layout.Images, expW, expH); err != nil { + // Minimum-size floor (#348). The floor lives in data-ingestors only + // on develop (di#348/#356); the DEPLOYED ingestor (v0.5.7/v0.6.0) has + // no floor and ingests small images fine. So the preview must NOT + // apply the 32x32 default on its own — a default block would reject an + // ingest the live cluster accepts, the inverse of the tabular-BOM + // block (whose reject mirrors a real deployed rejection). Apply the + // floor ONLY when the customer explicitly set --min-size (spec.MinSize) + // — their own declared requirement, honored locally regardless of the + // cluster. Once di#348 reaches prod, default this to MinImageSize and + // flip the imgc-too-small parity case so the floor is previewed by + // default. The emit side already matches: it omits file_options.min_size + // when unset, letting whichever ingestor is deployed apply its own + // default (none today; MinImageSize post-#348). + minW, minH := 0, 0 + if len(spec.MinSize) == 2 { + minW, minH = spec.MinSize[0], spec.MinSize[1] + } + if err := ValidateImages(layout.Images, expW, expH, minW, minH); err != nil { return nil, dataProblem(err) } if err := CheckHasDataRows(layout.LabelsCSV); err != nil { diff --git a/internal/push/preflight_test.go b/internal/push/preflight_test.go index ed546f4e..38ff928f 100644 --- a/internal/push/preflight_test.go +++ b/internal/push/preflight_test.go @@ -127,28 +127,77 @@ func TestValidateImages(t *testing.T) { zero := write("zero.png", nil) corrupt := write("corrupt.png", []byte("not an image at all")) - if err := ValidateImages([]string{good}, 8, 8); err != nil { + // minW/minH of 0 disables the floor so these decode/mismatch cases + // exercise the same behavior as before the #348 floor landed (the + // 8x8 / 4x4 fixtures are below the real 32x32 default). + if err := ValidateImages([]string{good}, 8, 8, 0, 0); err != nil { t.Errorf("valid image rejected: %v", err) } - if err := ValidateImages([]string{good, zero}, 8, 8); err == nil { + if err := ValidateImages([]string{good, zero}, 8, 8, 0, 0); err == nil { t.Fatal("zero-byte image must be rejected (cli#72b)") } else if !strings.Contains(err.Error(), "0 bytes") { t.Errorf("zero-byte diagnosis missing: %v", err) } - if err := ValidateImages([]string{good, corrupt}, 8, 8); err == nil { + if err := ValidateImages([]string{good, corrupt}, 8, 8, 0, 0); err == nil { t.Fatal("corrupt image must be rejected (cli#72b)") } - if err := ValidateImages([]string{good, odd}, 8, 8); err == nil { + if err := ValidateImages([]string{good, odd}, 8, 8, 0, 0); err == nil { t.Fatal("resolution mismatch must be rejected (cli#72c — the ingestor validates, it does not resize)") } else if !strings.Contains(err.Error(), "4x4") || !strings.Contains(err.Error(), "8x8") { t.Errorf("mismatch error must show both sizes: %v", err) } // 0x0 expectation skips the resolution comparison entirely. - if err := ValidateImages([]string{good, odd}, 0, 0); err != nil { + if err := ValidateImages([]string{good, odd}, 0, 0, 0, 0); err != nil { t.Errorf("no expected size → no resolution rejection: %v", err) } } +// TestValidateImagesMinSize covers the #348 minimum-size floor preview: +// an image below the floor is rejected (naming the file, its dimensions, +// and the floor); an image exactly at the floor passes; the floor takes +// precedence over a target_size mismatch; and it mirrors the ingestor's +// default (push.MinImageSize). +func TestValidateImagesMinSize(t *testing.T) { + dir := t.TempDir() + write := func(name string, w, h int) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, pngBytes(t, w, h), 0o644); err != nil { + t.Fatal(err) + } + return p + } + minW, minH := MinImageSize[0], MinImageSize[1] // 32x32, mirrors data-ingestors #348 + + atFloor := write("at_floor.png", minW, minH) + aboveFloor := write("above.png", minW+16, minH+16) + belowW := write("below_w.png", minW-1, minH) // one side under → too small + tiny := write("tiny.png", 8, 8) // both sides under + + // At or above the floor passes (exact-floor image is accepted). + if err := ValidateImages([]string{atFloor, aboveFloor}, 0, 0, minW, minH); err != nil { + t.Errorf("at/above-floor images rejected: %v", err) + } + // One side below the floor → rejected, naming the file, its size, and the floor. + err := ValidateImages([]string{atFloor, belowW}, 0, 0, minW, minH) + if err == nil { + t.Fatal("below-floor image must be rejected (#348)") + } + for _, want := range []string{"below_w.png", "31x32", "32x32", "min-size"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("too-small error missing %q: %v", want, err) + } + } + // The floor takes precedence over a target_size mismatch: tiny is both + // below the floor AND != the 64x64 target, but the too-small message wins. + err = ValidateImages([]string{tiny}, 64, 64, minW, minH) + if err == nil { + t.Fatal("tiny image must be rejected") + } + if !strings.Contains(err.Error(), "minimum") { + t.Errorf("floor must take precedence over the mismatch message: %v", err) + } +} + func TestCrossCheckLabels(t *testing.T) { dir := t.TempDir() imgs := filepath.Join(dir, "images") diff --git a/internal/push/spec.go b/internal/push/spec.go index dbee3fe0..73dafcd6 100644 --- a/internal/push/spec.go +++ b/internal/push/spec.go @@ -47,6 +47,24 @@ import ( // table-naming style anyway. var tableNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) +// MinImageSize is the absolute lower bound on image dimensions as +// [width, height] in pixels — the CLI's mirror of the ingestor's +// ImageResolutionValidator.MIN_IMAGE_SIZE (data-ingestors #348). +// Images with either side below this are rejected in-cluster as +// too small to train on, independently of the target_size uniformity +// check. A per-model override travels in spec.file_options.min_size +// (SpecArgs.MinSize); when unset, the ingestor applies this default (on +// develop — see below). The CLI preview does NOT default to it: it only +// previews the floor when the customer set --min-size, because the DEPLOYED +// ingestor (v0.5.7/v0.6.0) has no floor yet, so a default local block would +// reject an ingest the live cluster accepts (see PreflightDataset). This +// constant is still the mirror of the upstream default — once di#348 reaches +// prod, PreflightDataset should default the preview floor to it. +// +// Keep this in lock-step with the upstream constant — it is the source of +// truth. Do NOT invent a different floor here. +var MinImageSize = [2]int{32, 32} + // MaxTableNameLength caps `--table` at 63 chars. Two hard limits // agree on this: // @@ -162,6 +180,15 @@ type SpecArgs struct { // re-introduce a swap. (See the inline comment in buildImage.) TargetSize []int + // MinSize, when len==2, holds the minimum acceptable image size as + // [W, H] — the override for the ingestor's minimum-image-size floor + // (data-ingestors #348). Emitted as spec.file_options.min_size. + // Empty (len 0) ⇒ omit, and both the CLI preview and the ingestor + // fall back to MinImageSize (32x32). Populated by the CLI from + // --min-size. Same [W, H] order as TargetSize — no swap. (Image + // categories only.) + MinSize []int + // Schema is the column→SQL-type map for tabular / time-series // categories (required by the schema for those). Populated by the // CLI from --schema or by inferring types from the CSV. Ignored @@ -285,6 +312,16 @@ func (a SpecArgs) buildImage(spec map[string]any, prefix string) { fileOptions["extension"] = a.Extension } + // Minimum-size floor override (#348). Emitted under file_options for + // every image category (the schema places min_size there — there is no + // top-level min_size like keypoint's target_size). [width, height] — + // same contract as target_size, no swap. Omitted when unset so the + // ingestor's MIN_IMAGE_SIZE default (32x32) applies, matching the + // CLI's own preview default. + if len(a.MinSize) == 2 { + fileOptions["min_size"] = []int{a.MinSize[0], a.MinSize[1]} + } + if a.Category == "keypoint_detection" { if len(a.TargetSize) == 2 { // Emitted as [width, height] — the schema's own description diff --git a/internal/push/spec_test.go b/internal/push/spec_test.go index 27c7cb1d..65e2becb 100644 --- a/internal/push/spec_test.go +++ b/internal/push/spec_test.go @@ -181,6 +181,65 @@ func TestBuild_WithTargetSize_PassesSchema(t *testing.T) { } } +// TestBuild_WithMinSize_PassesSchema pins the #183 plumbing: when the +// customer overrides the minimum-image-size floor (--min-size → +// SpecArgs.MinSize), Build emits spec.file_options.min_size as +// [width, height] and the result still validates against the embedded v1 +// schema (which learned about min_size in the #348 re-sync). +func TestBuild_WithMinSize_PassesSchema(t *testing.T) { + spec := SpecArgs{ + Table: "cats_dogs_train", + Category: "image_classification", + Intent: "train", + LabelColumn: "label", + MinSize: []int{64, 48}, // non-square, to also lock the [W, H] order + }.Build() + + fo, ok := spec["spec"].(map[string]any)["file_options"].(map[string]any) + if !ok { + t.Fatalf("spec.file_options missing/wrong type: %#v", spec["spec"]) + } + ms, ok := fo["min_size"].([]int) + if !ok || len(ms) != 2 || ms[0] != 64 || ms[1] != 48 { + t.Fatalf("spec.file_options.min_size = %#v, want [64 48] (width, height)", fo["min_size"]) + } + + specBytes, err := yaml.Marshal(spec) + if err != nil { + t.Fatalf("yaml.Marshal: %v", err) + } + v, err := schema.NewV1Validator() + if err != nil { + t.Fatalf("NewV1Validator: %v", err) + } + _, errs, parseErr := v.ValidateYAML(specBytes) + if parseErr != nil { + t.Fatalf("ValidateYAML parse error on our own output: %v\n%s", parseErr, specBytes) + } + if len(errs) != 0 { + t.Fatalf("spec with min_size failed schema validation: %s\nspec:\n%s", + schema.FormatErrors(errs), specBytes) + } +} + +// TestBuild_NoMinSize_OmitsMinSize: with no --min-size override, Build must +// NOT emit file_options.min_size — both the CLI preview and the ingestor +// then fall back to the shared 32x32 default (MinImageSize / MIN_IMAGE_SIZE). +func TestBuild_NoMinSize_OmitsMinSize(t *testing.T) { + spec := SpecArgs{ + Table: "t", + Category: "image_classification", + Intent: "train", + LabelColumn: "label", + TargetSize: []int{64, 64}, // emits a spec block, but min_size must be absent + }.Build() + if fo, ok := spec["spec"].(map[string]any)["file_options"].(map[string]any); ok { + if _, present := fo["min_size"]; present { + t.Errorf("Build() with no MinSize emitted file_options.min_size; want omitted") + } + } +} + // TestBuild_NoTargetSize_OmitsSpecBlock: when no resolution is set, // Build must NOT emit a spec block (the ingestor's per-category // default applies). Asserting the omission keeps the minimal-spec diff --git a/internal/push/testdata/parity/cases.json b/internal/push/testdata/parity/cases.json index b54b8729..fc61d9fc 100644 --- a/internal/push/testdata/parity/cases.json +++ b/internal/push/testdata/parity/cases.json @@ -25,8 +25,8 @@ "csv": "data.csv", "label_column": "label", "cli_verdict": "reject", - "ingestor_verdict": "reject", - "note": "the stdlib header probe does not strip the BOM \u2014 false in-cluster rejection the CLI must preview (cli#71)" + "ingestor_verdict": "accept", + "note": "DELIBERATE divergence (surfaced by the #348 schema re-sync, which pinned the ref past data-ingestors #338): at THIS pinned ref the in-cluster tabular schema probe now strips the BOM, so the ingestor accepts. But the DEPLOYED ingestor (v0.5.7) predates #338 and still falsely rejects a BOM'd tabular CSV post-upload, so the CLI keeps previewing that rejection (CheckTabularBOM, cli#71). Drop this divergence once #338 ships to prod. Flagged for follow-up." }, { "name": "tabular-header-only", @@ -53,8 +53,8 @@ "label_column": "label", "extension": ".jpg", "target_size": [ - 8, - 8 + 32, + 32 ], "cli_verdict": "accept", "ingestor_verdict": "accept", @@ -67,8 +67,8 @@ "label_column": "label", "extension": ".jpg", "target_size": [ - 8, - 8 + 32, + 32 ], "cli_verdict": "accept", "ingestor_verdict": "accept", @@ -82,8 +82,8 @@ "label_column": "label", "extension": ".jpg", "target_size": [ - 8, - 8 + 32, + 32 ], "cli_verdict": "reject", "ingestor_verdict": "reject", @@ -96,8 +96,8 @@ "label_column": "label", "extension": ".jpg", "target_size": [ - 8, - 8 + 32, + 32 ], "cli_verdict": "accept", "ingestor_verdict": "accept", @@ -111,8 +111,8 @@ "label_column": "label", "extension": ".jpg", "target_size": [ - 8, - 8 + 32, + 32 ], "cli_verdict": "reject", "ingestor_verdict": "reject", @@ -125,8 +125,8 @@ "label_column": "label", "extension": ".jpg", "target_size": [ - 8, - 8 + 32, + 32 ], "cli_verdict": "reject", "ingestor_verdict": "reject", @@ -139,13 +139,46 @@ "label_column": "label", "extension": ".jpg", "target_size": [ - 8, - 8 + 32, + 32 ], "cli_verdict": "reject", "ingestor_verdict": "reject", "note": "ImageResolutionValidator: exact equality, no resize (cli#72c)" }, + { + "name": "imgc-too-small", + "category": "image_classification", + "csv": "labels.csv", + "label_column": "label", + "extension": ".jpg", + "target_size": [ + 16, + 16 + ], + "cli_verdict": "accept", + "ingestor_verdict": "reject", + "note": "DELIBERATE divergence (deployment parity, #348): 16x16 images are below the 32x32 default floor, so the pinned ingestor (develop, di#348/#356) rejects. But the CLI must NOT block by default — the DEPLOYED ingestor (v0.5.7/v0.6.0) has NO floor and ingests these fine, so a default local reject would block an ingest the live cluster accepts (the inverse of tabular-bom, whose reject mirrors a real deployed rejection). The CLI previews the floor ONLY when --min-size is set (see imgc-min-size-override). Flip this to reject and default the preview floor once di#348 ships to prod. Flagged for follow-up." + }, + { + "name": "imgc-min-size-override", + "category": "image_classification", + "csv": "labels.csv", + "label_column": "label", + "extension": ".jpg", + "target_size": [ + 24, + 40 + ], + "min_size": [ + 16, + 32 + ], + "cli_verdict": "accept", + "ingestor_verdict": "accept", + "note": "the --min-size override, cross-checked end-to-end (#348; closes the parity gap where min_size was representable on neither side). 24x40 images (W,H) with an explicit min_size [16,32] pass on BOTH sides — but the DEFAULT 32x32 floor would reject them (W=24<32), so this proves the override is actually read (not silently the default) and its [W,H] orientation is honored (a swapped [32,16] would reject on W=24<32). target_size matches the images, so the floor override is the sole discriminator.", + "value_parity": true + }, { "name": "imgc-header-only", "category": "image_classification", @@ -153,8 +186,8 @@ "label_column": "label", "extension": ".jpg", "target_size": [ - 8, - 8 + 32, + 32 ], "cli_verdict": "reject", "ingestor_verdict": "reject", @@ -167,8 +200,8 @@ "label_column": "label", "extension": ".jpg", "target_size": [ - 8, - 8 + 32, + 32 ], "cli_verdict": "reject", "ingestor_verdict": "accept", @@ -181,8 +214,8 @@ "label_column": "label", "extension": ".jpg", "target_size": [ - 8, - 8 + 32, + 32 ], "cli_verdict": "reject", "ingestor_verdict": "reject", @@ -195,12 +228,12 @@ "label_column": "label", "extension": ".jpg", "target_size": [ - 8, - 4 + 64, + 32 ], "cli_verdict": "accept", "ingestor_verdict": "accept", - "note": "non-square [W,H]=[8,4]: pins the target_size ORIENTATION end-to-end \u2014 an [H,W] swap on emit (the pre-P3 bug) flips this to reject in-cluster", + "note": "non-square [W,H]=[64,32] (both sides >= the 32x32 floor): pins the target_size ORIENTATION end-to-end \u2014 an [H,W] swap on emit (the pre-P3 bug) flips this to reject in-cluster", "value_parity": true }, { @@ -210,12 +243,12 @@ "label_column": "label", "extension": ".jpg", "target_size": [ - 4, - 8 + 32, + 64 ], "cli_verdict": "reject", "ingestor_verdict": "reject", - "note": "the same 8\u00d74 images with target [4,8]: both sides must reject \u2014 proves the comparison is orientation-sensitive, not shape-normalized" + "note": "the same 64\u00d732 images with target [32,64]: both sides must reject \u2014 proves the comparison is orientation-sensitive, not shape-normalized" }, { "name": "imgc-dup-header", @@ -224,8 +257,8 @@ "label_column": "label", "extension": ".jpg", "target_size": [ - 8, - 8 + 32, + 32 ], "cli_verdict": "reject", "ingestor_verdict": "accept", @@ -238,8 +271,8 @@ "label_column": "label", "extension": ".jpg", "target_size": [ - 8, - 8 + 32, + 32 ], "cli_verdict": "accept", "ingestor_verdict": "accept", @@ -253,8 +286,8 @@ "label_column": "label", "extension": ".jpg", "target_size": [ - 8, - 8 + 32, + 32 ], "cli_verdict": "accept", "ingestor_verdict": "accept", diff --git a/internal/push/testdata/parity/cases/imgc-bom-labels/images/a.jpg b/internal/push/testdata/parity/cases/imgc-bom-labels/images/a.jpg index 2c5a1e731107d9dcd1cdcd8a822612c6d01d9017..6520d3907647976516c4c9c3aaddbec125af8aef 100644 GIT binary patch delta 176 zcmey#(!x4HvYwfV350-v1&CNVSXo(ESh?8Q**JK(czC$ExVd@xgaml`1o^nR1;hjd zg+)X~MS1weCB#G|ghWI|{@-SBW@HBHVPWNDW#ts%<>nP30q{fA{y)GV$ibk%ps;cI Y2}aT9FBWxu=$VSiD#OVKR24_Z2PEHnP30q{fA{y)GV$ibk%ps;cI Y2}aT9FBWxu=$VSiD#OVKR24_Z2PEHnP30q{fA{y)GV$ibk%ps;cI Y2}aT9FBWxu=$VSiD#OVKR24_Z2PEHnP30q{fA{y)GV$ibk%ps;cI Y2}aT9FBWxuU00000 delta 164 zcmZo+{lPLpvYvyDjh&5^gPon7laqssM}(J$o0~^cNSI$lR!Uw@R!T-jK}AnpK}knh zMn=P2Q^&y2#Kc5i-O|>=$VSiD#OVKR24_Z2PEH^zXnP30q{fA{y)GV$ibk%ps;cI Y2}aT9FBWxuU00000 delta 164 zcmZo+{lPLpvYvyDjh&5^gPon7laqssM}(J$o0~^cNSI$lR!Uw@R!T-jK}AnpK}knh zMn=P2Q^&y2#Kc5i-O|>=$VSiD#OVKR24_Z2PEH^zXnP30q{fA{y)GV$ibk%ps;cI Y2}aT9FBWxuU00000 delta 164 zcmZo+{lPLpvYvyDjh&5^gPon7laqssM}(J$o0~^cNSI$lR!Uw@R!T-jK}AnpK}knh zMn=P2Q^&y2#Kc5i-O|>=$VSiD#OVKR24_Z2PEH^zXnP30q{fA{y)GV$ibk%ps;cI Y2}aT9FBWxuU00000 delta 164 zcmZo+{lPLpvYvyDjh&5^gPon7laqssM}(J$o0~^cNSI$lR!Uw@R!T-jK}AnpK}knh zMn=P2Q^&y2#Kc5i-O|>=$VSiD#OVKR24_Z2PEH^zXnP30q{fA{y)GV$ibk%ps;cI Y2}aT9FBWxuU00000 delta 164 zcmZo+{lPLpvYvyDjh&5^gPon7laqssM}(J$o0~^cNSI$lR!Uw@R!T-jK}AnpK}knh zMn=P2Q^&y2#Kc5i-O|>=$VSiD#OVKR24_Z2PEH^zXnP30q{fA{y)GV$ibk%ps;cI Y2}aT9FBWxuU00000 delta 164 zcmZo+{lPLpvYvyDjh&5^gPon7laqssM}(J$o0~^cNSI$lR!Uw@R!T-jK}AnpK}knh zMn=P2Q^&y2#Kc5i-O|>=$VSiD#OVKR24_Z2PEH^zXnP30q{fA{y)GV$ibk%ps;cI Y2}aT9FBWxu=$VSiD#OVKR24_Z2PEHnP30q{fA{y)GV$ibk%ps;cI Y2}aT9FBWxu=$VSiD#OVKR24_Z2PEHnP30q{fA{y)GV$ibk%ps;cI Y2}aT9FBWxu=$VSiD#OVKR24_Z2PEHnP30q{fA{y)GV$ibk%ps;cI Y2}aT9FBWxu=$VSiD#OVKR24_Z2PEHnP30q{fA{y)GV$ibk%ps;cI Y2}aT9FBWxu=$VSiD#OVKR24_Z2PEHnP30q{fA{y)GV$ibk%ps;cI Y2}aT9FBWxu=$VSiD#OVKR24_Z2PEH^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<c1}I=;VrF4wW9Q)H;sz?% zD!{d!pzFb!U9xX3zTPI5o8roG<0MW4oqZMDikqloVbuf*=gfJ(V&YTRE(2~ znmD<{#3dx9RMpfqG__1j&CD$#!xN1~T_61~Gj(Y!K*#+5Zvp^bsMbXQ literal 0 HcmV?d00001 diff --git a/internal/push/testdata/parity/cases/imgc-min-size-override/images/b.jpg b/internal/push/testdata/parity/cases/imgc-min-size-override/images/b.jpg new file mode 100644 index 0000000000000000000000000000000000000000..58494a06b7f86fca1eecf96f80e67734c34e022d GIT binary patch literal 653 zcmex=^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<c1}I=;VrF4wW9Q)H;sz?% zD!{d!pzFb!U9xX3zTPI5o8roG<0MW4oqZMDikqloVbuf*=gfJ(V&YTRE(2~ znmD<{#3dx9RMpfqG__1j&CD$#!)GpyMf-g$4s~fPU_!(E|8D{SPmR^Y literal 0 HcmV?d00001 diff --git a/internal/push/testdata/parity/cases/imgc-min-size-override/labels.csv b/internal/push/testdata/parity/cases/imgc-min-size-override/labels.csv new file mode 100644 index 00000000..aa98ba50 --- /dev/null +++ b/internal/push/testdata/parity/cases/imgc-min-size-override/labels.csv @@ -0,0 +1,3 @@ +image_id,label +a.jpg,cat +b.jpg,dog diff --git a/internal/push/testdata/parity/cases/imgc-missing-file/images/a.jpg b/internal/push/testdata/parity/cases/imgc-missing-file/images/a.jpg index 2c5a1e731107d9dcd1cdcd8a822612c6d01d9017..6520d3907647976516c4c9c3aaddbec125af8aef 100644 GIT binary patch delta 176 zcmey#(!x4HvYwfV350-v1&CNVSXo(ESh?8Q**JK(czC$ExVd@xgaml`1o^nR1;hjd zg+)X~MS1weCB#G|ghWI|{@-SBW@HBHVPWNDW#ts%<>nP30q{fA{y)GV$ibk%ps;cI Y2}aT9FBWxu=$VSiD#OVKR24_Z2PEH3G#7s3y28_ z3X6z}it_M_ONfa`2#JV_{J+iM%*YJX!@|nR%E~Fi%grl70^o4f*02GWMkN^Mx delta 164 zcmbQj`h#VHWIYEP8#@~-2Rl1ECnpCNj|eXhH#d)@kTAc9tdzW*tdxw5f{LEHf|8E1 zjEsi4rjCK3iHV84x}~j!k&T|QiP8Vt49<+4oSZz|JQBRT5=M$Libf;=eu&!t2N(o7 X7+4rMHZDKG$oVj1QP;;l3G#7s3y28_ z3X6z}it_M_ONfa`2#JV_{J+iM%*YJX!@|nR%E~Fi%grl70^o4f*02GWMkN^Mx delta 164 zcmbQj`h#VHWIYEP8#@~-2Rl1ECnpCNj|eXhH#d)@kTAc9tdzW*tdxw5f{LEHf|8E1 zjEsi4rjCK3iHV84x}~j!k&T|QiP8Vt49<+4oSZz|JQBRT5=M$Libf;=eu&!t2N(o7 X7+4rMHZDKG$oVj1QP;;l3G#7s3y28_ z3X6z}it_M_ONfa`2#JV_{J+iM%*YJX!@|nR%E~Fi%grl70^o4f*02GWMkN^Mx delta 164 zcmbQj`h#VHWIYEP8#@~-2Rl1ECnpCNj|eXhH#d)@kTAc9tdzW*tdxw5f{LEHf|8E1 zjEsi4rjCK3iHV84x}~j!k&T|QiP8Vt49<+4oSZz|JQBRT5=M$Libf;=eu&!t2N(o7 X7+4rMHZDKG$oVj1QP;;l3G#7s3y28_ z3X6z}it_M_ONfa`2#JV_{J+iM%*YJX!@|nR%E~Fi%grl70^o4f*02GWMkN^Mx delta 164 zcmbQj`h#VHWIYEP8#@~-2Rl1ECnpCNj|eXhH#d)@kTAc9tdzW*tdxw5f{LEHf|8E1 zjEsi4rjCK3iHV84x}~j!k&T|QiP8Vt49<+4oSZz|JQBRT5=M$Libf;=eu&!t2N(o7 X7+4rMHZDKG$oVj1QP;;lnP30q{fA{y)GV$ibk%ps;cI Y2}aT9FBWxu=$VSiD#OVKR24_Z2PEHnP30q{fA{y)GV$ibk%ps;cI Y2}aT9FBWxu=$VSiD#OVKR24_Z2PEHnP30q{fA{y)GV$ibk%ps;cI Y2}aT9FBWxu=$VSiD#OVKR24_Z2PEH3G#7s3y28_ z3X6z}it_M_ONfa`2#JV_{J+iM%*YJX!@|nR%E~Fi%grl70^oR^ Y6O5YAUo7hS$ai5;mj(kK%>4f*0Gkpa?EnA( delta 165 zcmbQi`jcgXWIYEP8#@~-2Rl1ECnpCNj|eXhH#d)@kTAc9tdzW*tdxw5f{LEHf|8E1 zjEsi4rjCK3iHV84x}~j!k&T|QiP8Vt49<+4oSZz|JQBRT5=M$Libf;=eu&!t2N(o7 Z7+4rsHZDKG$o08PW6_TO754vc0sw*fA!Yyo diff --git a/internal/push/testdata/parity/cases/imgc-too-small/images/a.jpg b/internal/push/testdata/parity/cases/imgc-too-small/images/a.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7f0770f2afff74a46dd075cce4d33051fba53fa4 GIT binary patch literal 632 zcmex=``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivX!XqN1l2cOC(lau%ic3n%$}1|Xnp;}i+B-VC zCQY6)b=ve9GiNPYykzOJeA&aSFc^aar4&0 zM~|O8efIpt%U2&ieg5+G+xH(oe}VkP$iNKo7LbH^49#DHKz}i@urRZ*gZ#zFR1U<< zf-J0xhHOHPf$WKe!b(Ps93oB=7j8VrscandK{To8BA1wo$wSqTAg_UaMx4i*$nqK7 eV+eoUV&GwB1V$dSAcH-_^B0S{KJs1ue-i+7``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivX!XqN1l2cOC(lau%ic3n%$}1|Xnp;}i+B-VC zCQY6)b=ve9GiNPYykzOJeA&aSFc^aar4&0 zM~|O8efIpt%U2&ieg5+G+xH(oe}VkP$iNKo7LbH^49#DHKz}i@urRZ*gZ#zFR1U<< zf-J0xhHOHPf$WKe!b(Ps93oB=7j8VrscandK{To8BA1wo$wSqTAg_UaMx4i*$nqK7 eV+eoUV&GwB1V$dSAcH-_^B0S{KJs1ue-i+7nP30q{fA{y)GV$ibk%ps;cI Y2}aT9FBWxu=$VSiD#OVKR24_Z2PEH.1' by the parser and the schema maps onto the wrong column." diff --git a/internal/schema/ingest.v1.json b/internal/schema/ingest.v1.json index ea371d1d..b6f6bef7 100644 --- a/internal/schema/ingest.v1.json +++ b/internal/schema/ingest.v1.json @@ -204,6 +204,13 @@ "maxItems": 2, "description": "[width, height]. Image categories only. Default [512, 512]. The order matches PIL.Image.size and what ImageResolutionValidator expects." }, + "min_size": { + "type": "array", + "items": { "type": "integer", "minimum": 1 }, + "minItems": 2, + "maxItems": 2, + "description": "[width, height] absolute minimum image size (#348). Images with either side below this are rejected as too small to train on. Image categories only. Defaults to [32, 32] (ImageResolutionValidator.MIN_IMAGE_SIZE) when unset; override per-model to match the model-zoo input requirement." + }, "extension": { "type": "string", "enum": [".jpg", ".jpeg", ".png", ".txt", ".text", ".xml"], diff --git a/scripts/.data-ingestors-ref b/scripts/.data-ingestors-ref index c441fc2c..843291e2 100644 --- a/scripts/.data-ingestors-ref +++ b/scripts/.data-ingestors-ref @@ -9,4 +9,4 @@ # # Format: the first non-comment, non-blank line is the ref (a full commit SHA # preferred; a branch name works but reintroduces floating drift). -0de1f148f9f19c8838d275ab9e5295ae224385c2 +efaeb07185c42556f833e876cb17791f30f4916d diff --git a/scripts/gen-validator-goldens.py b/scripts/gen-validator-goldens.py index 99ed3b20..02ef6893 100644 --- a/scripts/gen-validator-goldens.py +++ b/scripts/gen-validator-goldens.py @@ -134,6 +134,12 @@ def run_case(case): options["extension"] = case["extension"] if case.get("target_size"): options["target_size"] = case["target_size"] + if case.get("min_size"): + # The --min-size floor override (#348), cross-checked end-to-end: + # the image factory reads options["min_size"] into + # ImageResolutionValidator, so a per-case override drives the REAL + # validator the same way the Go preview drives SpecArgs.MinSize. + options["min_size"] = case["min_size"] if case["category"].startswith(("tabular", "time_")): # An explicit per-case schema (mirroring --schema) wins; else # infer — BOTH sides of the harness use the same source so From 706f44ea6051ec01f44101ad0b404cbbc6a8b138 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:35:40 +0200 Subject: [PATCH 08/22] feat(data ingest): wire the 5 text tasks (#182) (#209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(data ingest): wire the 5 CLI-pending text tasks (RFC-0002 phase 4) Wire token_classification, sentence_pair_classification, causal_language_modeling, seq2seq, and embeddings so the family-scoped picker offers them under "Available now" — the ingestor, schema, and backend already support them; CLI-side discovery/staging was the only gap. The CLI now MIRRORS data-ingestors' machine-readable layout contract (di#347/#353) rather than forking its layout rules (RFC-0002 Principle 6): - Vendor layout.v1.json into internal/schema/ + embed it; extend scripts/sync-schema.sh to sync + drift-check both contract files. Bump scripts/.data-ingestors-ref to the #353 merge commit (3c63d9a), which keeps ingest.v1.json byte-identical (no unrelated drift). - internal/push/layout_contract.go parses the contract and drives the ENFORCED record-format checks (sentence_pair: text_atext_b; embeddings: anchorpositive[negative]) at discovery, mirroring the ingestor's TabSeparatedRecordValidator. Unenforced formats (seq2seq, causal LM) accept raw text, so the mirror doesn't reject them. - Fix the self-supervised flags for seq2seq + embeddings (no label question), and emit the label for the supervised text tasks. - Flip the 5 CLISupported=true; a drift test pins the Go registry's family/label/subdir facts against the vendored contract. semantic_segmentation stays CLI-pending: it awaits the ingestor's mask_id link column + training sign-off (backend#816). Co-Authored-By: Claude Opus 4.8 * fix(data ingest): address #182 review findings — mirror the ingestor exactly Verified review findings on the text-task wiring, fixed minimally so the CLI never forks a fact the layout contract / ingestor owns (RFC-0002 Principle 6): - text.go: the enforced record-format check (sentence_pair, embeddings) now runs only over the .txt files labels.csv references, mirroring the ingestor's TabSeparatedRecordValidator manifest walk — a stray unreferenced .txt no longer fails discovery on a layout the cluster accepts. - preflight.go: the text-family label preflight was hardcoded to text_classification; gate the label-column check on !SelfSupervisedText and the diversity check on IsClassification, so sentence_pair_classification and token_classification get the right previews (token_classification is NOT is_classification, so it skips diversity — the ingestor runs BIOLabelValidator instead). Adds IsClassification to the registry, mirroring the ingestor's ModalitySpec.is_classification. - category.go: derive TextSidecarDir from the contract's primary_subdir instead of a hardcoded MLM special-case; refresh the SelfSupervised docs to cover seq2seq/embeddings (target from the record's paired fields, not the text). - data.go: refresh the stale text-branch comment (7 tasks, supervised split). - sync-schema.sh: restore signal-safe temp-file cleanup (EXIT/INT/TERM, not a RETURN trap) and make a failed write in write mode return non-zero instead of a false "wrote" + exit 0 under the `if ! sync_one` errexit suspension. Part of #182. Co-Authored-By: Claude Opus 4.8 * fix(data ingest): make the enforced text-record check match discovered files, not reconstructed names (#209) Address Asad's review on #209. The enforced text-record check reconstructed ".txt" from the manifest instead of matching the files actually discovered on disk, so it fail-opened in several ways. Mirror the ingestor's TabSeparatedRecordValidator exactly (RFC-0002 Principle 6): - Match each manifest filename against the discovered sidecar basenames (case-insensitive on basename and stem), so a row "a" resolves to texts/a.text when the ingestor's configured extension is .text — no more hardcoded ".txt". - Require the filename column locally: a manifest without it now errors clearly (mirrors the ingestor's "Missing required column: filename") instead of silently validating nothing. - Read the manifest with LazyQuotes so a row pandas tolerates (an unescaped quote) is read here too, not silently dropped and left unvalidated. - Drive the field-count error message off the contract separator (sepLabel), so a future non-tab task isn't misdescribed as "tab-separated". - TextSidecarDir now fails loud on a text category missing from the vendored contract — that can only be a vendoring/drift bug, not a runtime condition. - sync-schema.sh checks curl's exit explicitly (a 404 was misdiagnosed as "not valid JSON" under the set -e-suspending `if ! sync_one`) and adds --tlsv1.2 to match the rest of the repo. - Dedupe: shared matchColumnIndex (column resolve) and openCSVReader (BOM-stripping CSV reader) helpers; slices.Contains over hand-rolled containsInt. Tests: .text extension validated, missing filename column errors, case- mismatched basename validated, pandas-tolerable/Go-strict row read, contract- driven message. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- internal/cli/data.go | 22 +- internal/cli/data_test.go | 14 +- internal/cli/interactive_test.go | 53 +++-- internal/push/category.go | 121 +++++++---- internal/push/category_registry_test.go | 24 ++- internal/push/layout_contract.go | 191 ++++++++++++++++++ internal/push/layout_contract_test.go | 173 ++++++++++++++++ internal/push/preflight.go | 116 ++++++----- internal/push/preflight_test.go | 47 +++++ internal/push/preview_test.go | 8 +- internal/push/spec.go | 15 +- internal/push/text.go | 137 +++++++++++++ internal/push/text_test.go | 257 ++++++++++++++++++++++++ internal/schema/embed.go | 15 ++ internal/schema/layout.v1.json | 217 ++++++++++++++++++++ scripts/sync-schema.sh | 158 ++++++++++----- 16 files changed, 1395 insertions(+), 173 deletions(-) create mode 100644 internal/push/layout_contract.go create mode 100644 internal/push/layout_contract_test.go create mode 100644 internal/schema/layout.v1.json diff --git a/internal/cli/data.go b/internal/cli/data.go index 74790d2e..812839df 100644 --- a/internal/cli/data.go +++ b/internal/cli/data.go @@ -559,12 +559,12 @@ collaborators can train against that table without ever seeing the raw files.`)) case push.IsCLISupported(a.Spec.Category): // supported case push.IsKnown(a.Spec.Category): - // A recognized category data ingest doesn't implement yet — image - // (semantic_segmentation) or text (causal_language_modeling, seq2seq, - // …). Routed here (not the default branch) so the - // user gets the registry's per-category pending-support reason, not a - // misleading "unrecognized category". Supported categories were already - // caught above, so IsKnown here means known-but-unsupported. + // A recognized category data ingest doesn't implement yet — today just + // semantic_segmentation (awaiting the ingestor's mask_id link column + + // training sign-off, backend#816). Routed here (not the default branch) + // so the user gets the registry's per-category pending-support reason, + // not a misleading "unrecognized category". Supported categories were + // already caught above, so IsKnown here means known-but-unsupported. spec, _ := push.Lookup(a.Spec.Category) return &exitError{code: 2, err: fmt.Errorf( "task %q isn't supported by the CLI yet (%s). Supported tasks: %s.", @@ -724,9 +724,13 @@ collaborators can train against that table without ever seeing the raw files.`)) } a.Spec.Extension = ext default: - // Text family: no extra per-category resolution. The label (for - // text_classification) comes straight from --label-column; - // masked_language_modeling needs neither a label nor a schema. + // Text family: no extra per-category resolution. The supervised text + // tasks (text_classification, token_classification, + // sentence_pair_classification) carry a label straight from + // --label-column; the self-supervised ones (masked/causal language + // modeling, seq2seq, embeddings) need neither a label nor a schema. + // buildText emits the label for exactly the supervised set, keyed on + // the registry's SelfSupervised flag (not a hardcoded id). } // 4. Synthesize the spec from flags + validate against schema. diff --git a/internal/cli/data_test.go b/internal/cli/data_test.go index 387ab464..1ca119c1 100644 --- a/internal/cli/data_test.go +++ b/internal/cli/data_test.go @@ -94,7 +94,7 @@ func execDataIngest(t *testing.T, args []string) (exitCode int, stdout, stderr s func TestDataIngest_UnsupportedCategory_ExitsTwo(t *testing.T) { root := imgcLayout(t) for _, badCategory := range []string{ - "semantic_segmentation", // known but blocked on the ingestor (data-ingestors#136) + "semantic_segmentation", // known but pending (awaiting mask_id + training sign-off, backend#816) "instance_segmentation", // dead — removed from the registry (#1005), now unrecognized "definitely-not-a-category", // nonsense; gate catches this too } { @@ -114,11 +114,11 @@ func TestDataIngest_UnsupportedCategory_ExitsTwo(t *testing.T) { } // TestDataIngest_KnownUnsupportedCategory_PendingNote pins the Bugbot fix -// (v0.4.0 RC): a registry-known but CLI-unsupported NON-image category -// (causal_language_modeling) must get the registry's pending-support note, not -// the misleading "isn't a recognized task category" message. execDataIngest -// discards the error and SilenceErrors swallows it, so run the command here and -// inspect the returned error directly. +// (v0.4.0 RC): a registry-known but CLI-unsupported category +// (semantic_segmentation — the sole remaining one after phase 4) must get the +// registry's pending-support note, not the misleading "isn't a recognized task +// category" message. execDataIngest discards the error and SilenceErrors +// swallows it, so run the command here and inspect the returned error directly. func TestDataIngest_KnownUnsupportedCategory_PendingNote(t *testing.T) { root := imgcLayout(t) rootCmd := NewRootCmd(BuildInfo{Version: "test"}) @@ -126,7 +126,7 @@ func TestDataIngest_KnownUnsupportedCategory_PendingNote(t *testing.T) { rootCmd.SetErr(&bytes.Buffer{}) rootCmd.SetArgs([]string{"data", "ingest", "--kubeconfig=/tmp/tracebloc-cli-test-nonexistent-" + t.Name(), - root, "--name=t1", "--task=causal_language_modeling", + root, "--name=t1", "--task=semantic_segmentation", "--intent=train", "--label-column=label"}) err := rootCmd.Execute() if err == nil { diff --git a/internal/cli/interactive_test.go b/internal/cli/interactive_test.go index c15e35a0..dc844eec 100644 --- a/internal/cli/interactive_test.go +++ b/internal/cli/interactive_test.go @@ -240,13 +240,13 @@ func TestRunInteractive_ExplicitTaskSkipsSniff(t *testing.T) { } // TestPickTask_FamilyScoped: the picker offers only the given family's -// tasks, wires the friendly display names + the locked glosses, and lists -// the not-yet-supported ones (greyed, with a reason) — never the other -// families' tasks. +// tasks, wires the friendly display names + the locked glosses — never the +// other families' tasks. After RFC-0002 phase 4 every text task is wired, so +// the text picker has no "Not yet in the CLI" section at all. func TestPickTask_FamilyScoped(t *testing.T) { - // Text family: fill-mask (gloss) is available; seq2seq - // (translation / summarization, gloss) + token_classification are - // pending; image/tabular tasks must not appear. + // Text family: all tasks are available now — fill-mask (gloss), + // classification, the two structured-pair tasks, and the two seq tasks; + // image/tabular tasks must not appear. f := &fakePrompter{answers: map[string]string{"Which task?": "Text classification"}} var buf bytes.Buffer p := ui.New(&buf, ui.WithColor(false)) @@ -260,17 +260,22 @@ func TestPickTask_FamilyScoped(t *testing.T) { out := buf.String() for _, want := range []string{ "Tasks for text data", - "fill-mask", // MLM gloss (available) - "Text classification", // label - "Not yet in the CLI:", // pending header - "translation / summarization", // seq2seq gloss (pending) - "token_classification", // pending id - "schema-recognized", // an UnsupportedNote fragment + "Available now:", + "fill-mask", // MLM gloss (available) + "Text classification", // label + "translation / summarization", // seq2seq gloss (now available) + "token_classification", // now available + "sentence_pair_classification", // now available + "Embeddings", // now available } { if !strings.Contains(out, want) { t.Errorf("picker output missing %q:\n%s", want, out) } } + // Every text task is wired now — no pending section. + if strings.Contains(out, "Not yet in the CLI:") { + t.Errorf("text picker should have no pending section now:\n%s", out) + } // Other families must not leak in. for _, unwanted := range []string{"Image classification", "Tabular classification", "Survival analysis"} { if strings.Contains(out, unwanted) { @@ -279,6 +284,30 @@ func TestPickTask_FamilyScoped(t *testing.T) { } } +// TestPickTask_ImagePending: semantic_segmentation is the sole remaining +// CLI-pending task, so the image picker still renders a greyed "Not yet in the +// CLI" section with its backend#816 reason. +func TestPickTask_ImagePending(t *testing.T) { + f := &fakePrompter{answers: map[string]string{"Which task?": "Image classification"}} + var buf bytes.Buffer + p := ui.New(&buf, ui.WithColor(false)) + if _, err := pickTask(p, f, push.FamilyImage); err != nil { + t.Fatalf("pickTask: %v", err) + } + out := buf.String() + for _, want := range []string{ + "Available now:", + "Image classification", + "Not yet in the CLI:", + "semantic_segmentation", + "backend#816", // the UnsupportedNote reason + } { + if !strings.Contains(out, want) { + t.Errorf("image picker missing %q:\n%s", want, out) + } + } +} + // TestPickTask_TabularGloss: the tabular picker shows the survival-analysis // gloss for time_to_event_prediction and can select it back to its id. func TestPickTask_TabularGloss(t *testing.T) { diff --git a/internal/push/category.go b/internal/push/category.go index 9edec1f8..a01d5dd3 100644 --- a/internal/push/category.go +++ b/internal/push/category.go @@ -1,6 +1,9 @@ package push -import "strings" +import ( + "fmt" + "strings" +) // CategorySpec is the single source of truth for one task category's // CLI-relevant rules. It mirrors data-ingestors' @@ -36,12 +39,26 @@ type CategorySpec struct { // never ships to the central backend by default. RegressionClass bool // SelfSupervised marks text categories that train without an explicit - // label column — the target is derived from the text itself (MLM masks - // tokens; CLM predicts the next token), so the interactive flow skips - // the "which column is the label?" question. A registry fact rather - // than a hardcoded id list so a new self-supervised task can't be added - // without deciding this (SelfSupervisedText reads it). + // label column — no `label` travels in labels.csv. For MLM/CLM the target + // is derived from the text itself (mask a token; predict the next one); for + // seq2seq and embeddings it comes from the record's own paired fields + // (source→target, anchor/positive/negative). Either way there's no label + // column, so the interactive flow skips the "which column is the label?" + // question. A registry fact rather than a hardcoded id list so a new + // self-supervised task can't be added without deciding this + // (SelfSupervisedText reads it). Mirrors the ingestor registry's + // is_self_supervised (data-ingestors modalities/registry.py). SelfSupervised bool + // IsClassification marks categories the ingestor treats as classification + // (registry ModalitySpec.is_classification) — the ones whose validator + // chain gets a LabelDiversityValidator, so the dataset needs >= 2 distinct + // labels. Mirrors the ingestor exactly: the image family + + // text_classification + sentence_pair_classification + tabular_classification + // are true; token_classification is NOT (its labels are BIO tag sequences, + // checked by BIOLabelValidator, not class labels), nor are the regression / + // self-supervised tasks. The label-diversity preflight reads it so it can't + // drift from the ingestor's wiring. + IsClassification bool // CLISupported reports whether `dataset push` implements the category // today. semantic_segmentation is known (the schema defines it) but // not yet pushable. @@ -76,17 +93,17 @@ const ( // nor carry an extra the ingestor won't accept (the instance_segmentation // half-ingest class — data-ingestors #240/#99, #1005). var categoryRegistry = []CategorySpec{ - {ID: "image_classification", Family: FamilyImage, Label: "Image classification", CLISupported: true, + {ID: "image_classification", Family: FamilyImage, Label: "Image classification", CLISupported: true, IsClassification: true, Blurb: "sort images into classes"}, - {ID: "object_detection", Family: FamilyImage, Label: "Object detection", CLISupported: true, + {ID: "object_detection", Family: FamilyImage, Label: "Object detection", CLISupported: true, IsClassification: true, Blurb: "draw boxes around objects in an image"}, - {ID: "keypoint_detection", Family: FamilyImage, Label: "Keypoint detection", CLISupported: true, + {ID: "keypoint_detection", Family: FamilyImage, Label: "Keypoint detection", CLISupported: true, IsClassification: true, Blurb: "locate landmark points on an image (e.g. pose)"}, - {ID: "text_classification", Family: FamilyText, Label: "Text classification", CLISupported: true, + {ID: "text_classification", Family: FamilyText, Label: "Text classification", CLISupported: true, IsClassification: true, Blurb: "sort text snippets into classes"}, {ID: "masked_language_modeling", Family: FamilyText, Label: "Masked language modeling", Gloss: "fill-mask", CLISupported: true, SelfSupervised: true, Blurb: "predict masked-out words — no labels needed"}, - {ID: "tabular_classification", Family: FamilyTabular, Label: "Tabular classification", CLISupported: true, + {ID: "tabular_classification", Family: FamilyTabular, Label: "Tabular classification", CLISupported: true, IsClassification: true, Blurb: "predict a class from table columns"}, {ID: "tabular_regression", Family: FamilyTabular, Label: "Tabular regression", RegressionClass: true, CLISupported: true, Blurb: "predict a number from table columns"}, @@ -94,24 +111,23 @@ var categoryRegistry = []CategorySpec{ Blurb: "predict future values from past ones"}, {ID: "time_to_event_prediction", Family: FamilyTabular, Label: "Time-to-event prediction", Gloss: "Survival analysis", RegressionClass: true, CLISupported: true, Blurb: "predict how long until an event happens"}, - {ID: "semantic_segmentation", Family: FamilyImage, Label: "Semantic segmentation", CLISupported: false, + {ID: "causal_language_modeling", Family: FamilyText, Label: "Causal language modeling", CLISupported: true, SelfSupervised: true, + Blurb: "predict the next word in a sequence"}, + {ID: "seq2seq", Family: FamilyText, Label: "Sequence-to-sequence", Gloss: "translation / summarization", CLISupported: true, SelfSupervised: true, + Blurb: "map an input sequence to an output one"}, + {ID: "token_classification", Family: FamilyText, Label: "Token classification", CLISupported: true, + Blurb: "label each word in a sequence"}, + {ID: "sentence_pair_classification", Family: FamilyText, Label: "Sentence-pair classification", CLISupported: true, IsClassification: true, + Blurb: "label how two texts relate"}, + {ID: "embeddings", Family: FamilyText, Label: "Embeddings", CLISupported: true, SelfSupervised: true, + Blurb: "learn vector representations from text pairs"}, + // semantic_segmentation stays CLI-pending: di#136 (mask sidecar) shipped, + // but the ingestor doesn't yet populate the mask_id link column the + // contract requires, and the training-side sign-off is tracked in + // backend#816. Wire it once those land (RFC-0002 phase 4 follow-up). + {ID: "semantic_segmentation", Family: FamilyImage, Label: "Semantic segmentation", CLISupported: false, IsClassification: true, Blurb: "label every pixel in an image", - UnsupportedNote: "blocked on the ingestor's mask-sidecar support (data-ingestors#136)"}, - {ID: "causal_language_modeling", Family: FamilyText, Label: "Causal language modeling", CLISupported: false, SelfSupervised: true, - Blurb: "predict the next word in a sequence", - UnsupportedNote: "schema-recognized (data-ingestors#805); `tracebloc ingest` discover/build for its raw-.txt / prompt\\tcompletion `texts` layout is pending"}, - {ID: "seq2seq", Family: FamilyText, Label: "Sequence-to-sequence", Gloss: "translation / summarization", CLISupported: false, - Blurb: "map an input sequence to an output one", - UnsupportedNote: "schema-recognized; `tracebloc ingest` discover/build for its raw-.txt / source\\ttarget `texts` layout is pending"}, - {ID: "token_classification", Family: FamilyText, Label: "Token classification", CLISupported: false, - Blurb: "label each word in a sequence", - UnsupportedNote: "schema-recognized; the CLI doesn't stage its per-token-label `texts` layout yet"}, - {ID: "sentence_pair_classification", Family: FamilyText, Label: "Sentence-pair classification", CLISupported: false, - Blurb: "label how two texts relate", - UnsupportedNote: "schema-recognized; `tracebloc ingest` discover/build for its raw-.txt / text_a\\ttext_b `texts` layout is pending"}, - {ID: "embeddings", Family: FamilyText, Label: "Embeddings", CLISupported: false, - Blurb: "learn vector representations from text pairs", - UnsupportedNote: "schema-recognized; `tracebloc ingest` discover/build for its raw-.txt / anchor\\tpositive[\\tnegative] `texts` layout is pending"}, + UnsupportedNote: "schema-recognized; awaiting the ingestor's mask_id link column + training sign-off (backend#816)"}, } // categoryByID indexes the registry for O(1) lookup, built once. @@ -206,12 +222,13 @@ func FamilyNouns() []string { } // SelfSupervisedText reports whether a text category trains without an -// explicit label column — the target is derived from the text itself, so -// the CLI skips the "which column is the label?" question. MLM masks -// tokens; CLM predicts the next token; neither reads a labels column. The -// answer is the registry's SelfSupervised flag, so a new self-supervised -// task is handled the moment it's added to the registry — not when someone -// remembers to edit this function. +// explicit label column, so the CLI skips the "which column is the label?" +// question. MLM/CLM derive the target from the text itself (mask a token; +// predict the next one); seq2seq and embeddings derive it from the record's +// own paired fields (source→target, anchor/positive/negative) — none reads a +// labels column. The answer is the registry's SelfSupervised flag, so a new +// self-supervised task is handled the moment it's added to the registry — not +// when someone remembers to edit this function. func SelfSupervisedText(category string) bool { c, ok := categoryByID[category] return ok && c.SelfSupervised @@ -249,6 +266,14 @@ func IsText(category string) bool { // therefore needs label.policy (object label form). func IsRegressionClass(category string) bool { return categoryByID[category].RegressionClass } +// IsClassification reports whether the ingestor treats category as a +// classification task (registry ModalitySpec.is_classification) — i.e. its +// validator chain includes LabelDiversityValidator. The label-diversity +// preflight gates on this so the CLI mirrors the ingestor's wiring rather than +// hardcoding a category id (which is exactly how the text-family preflight +// drifted when it only knew text_classification). +func IsClassification(category string) bool { return categoryByID[category].IsClassification } + // SupportedCategoryIDs returns the ids `dataset push` supports, in display // order. Used to build the --task help, the interactive picker, and // the accept-gate's "Supported:" lists from one place. @@ -275,13 +300,27 @@ func AllCategoryIDs() []string { // and gate error messages. func SupportedCategoriesList() string { return strings.Join(SupportedCategoryIDs(), ", ") } -// TextSidecarDir returns the sidecar directory name a text category -// expects: "sequences" for masked_language_modeling, "texts" for -// text_classification. (Used both as the local subdir to stage and the -// spec field to emit.) +// TextSidecarDir returns the sidecar directory name a text category expects +// ("sequences" for masked_language_modeling, "texts" for every other text +// task). Used both as the local subdir to stage and the spec field to emit. +// +// The value is READ from the vendored layout contract's primary_subdir — the +// ingestor owns this fact (data-ingestors registry ModalitySpec.file_subdir), +// so the CLI mirrors it rather than keeping a Go fork of the same rule +// (RFC-0002 Principle 6). +// +// A text category with no primary_subdir in the contract is a vendoring/drift +// bug, not a runtime condition: every text task pins one and +// TestTextSidecarDirMirrorsContract enforces the registry and contract agree. +// Silently falling back to "texts" would stage files into a directory the +// ingestor never reads, so fail loud instead — the only way here is a broken +// vendored contract, which scripts/sync-schema.sh --check catches in CI. func TextSidecarDir(category string) string { - if category == "masked_language_modeling" { - return "sequences" + if layout, ok := LayoutFor(category); ok && layout.PrimarySubdir != nil { + return *layout.PrimarySubdir } - return "texts" + panic(fmt.Sprintf( + "text category %q has no primary_subdir in the vendored layout contract — "+ + "the Go registry has drifted from layout.v1.json; re-run scripts/sync-schema.sh", + category)) } diff --git a/internal/push/category_registry_test.go b/internal/push/category_registry_test.go index 3548a381..7e7c0f43 100644 --- a/internal/push/category_registry_test.go +++ b/internal/push/category_registry_test.go @@ -37,17 +37,21 @@ func TestRegistryKnownCategories(t *testing.T) { func TestSupportedCategories(t *testing.T) { got := SupportedCategoryIDs() - if len(got) != 9 { - t.Fatalf("SupportedCategoryIDs() len = %d, want 9: %v", len(got), got) + // RFC-0002 phase 4 wired the 5 text tasks (token/sentence-pair + // classification, causal LM, seq2seq, embeddings), so 14 of the 15 + // categories are pushable; only semantic_segmentation remains pending. + if len(got) != 14 { + t.Fatalf("SupportedCategoryIDs() len = %d, want 14: %v", len(got), got) } for _, id := range got { if !IsCLISupported(id) { t.Errorf("SupportedCategoryIDs returned %q but IsCLISupported is false", id) } } - // semantic_segmentation + the self-supervised text categories (CLM, seq2seq) - // + token_classification are known but not yet pushable, and must explain why. - for _, id := range []string{"semantic_segmentation", "causal_language_modeling", "seq2seq", "token_classification", "sentence_pair_classification", "embeddings"} { + // semantic_segmentation is the sole known-but-not-yet-pushable category + // (awaiting the ingestor's mask_id link column + training sign-off, + // backend#816); it must stay gated out and explain why. + for _, id := range []string{"semantic_segmentation"} { if !IsKnown(id) { t.Errorf("%s should be known", id) } @@ -58,6 +62,16 @@ func TestSupportedCategories(t *testing.T) { t.Errorf("%s should carry an UnsupportedNote", id) } } + // The 5 newly-wired text tasks must now be pushable AND carry no stale + // pending note (the picker only greys out categories with a note). + for _, id := range []string{"token_classification", "sentence_pair_classification", "causal_language_modeling", "seq2seq", "embeddings"} { + if !IsCLISupported(id) { + t.Errorf("%s should be CLI-supported after phase 4", id) + } + if spec, _ := Lookup(id); spec.UnsupportedNote != "" { + t.Errorf("%s is supported but still carries an UnsupportedNote: %q", id, spec.UnsupportedNote) + } + } } func TestPredicatesDeriveFromRegistry(t *testing.T) { diff --git a/internal/push/layout_contract.go b/internal/push/layout_contract.go new file mode 100644 index 00000000..ca28af8a --- /dev/null +++ b/internal/push/layout_contract.go @@ -0,0 +1,191 @@ +package push + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + + "github.com/tracebloc/cli/internal/schema" +) + +// The per-task local dataset-layout contract, mirrored from data-ingestors' +// tracebloc_ingestor/schema/layout.v1.json (data-ingestors#347/#353), vendored +// into internal/schema/ and drift-checked by scripts/sync-schema.sh. +// +// The ingestor is the source of truth for what a task's local dataset looks +// like on disk. The CLI reads this contract so its discovery + staging is a +// VERIFIED MIRROR of the ingestor's rules rather than a Go fork of them +// (RFC-0002 Principle 6). Two things drive real behaviour here: +// +// - RecordFormat — the structure inside each .txt for the structured text +// tasks. For the ENFORCED formats (sentence_pair_classification, +// embeddings) the CLI rejects a malformed file before staging, exactly as +// the ingestor's TabSeparatedRecordValidator would in-cluster. +// - The manifest/family/subdir facts — pinned against the Go category +// registry by layout_contract_test.go, so category.go can't silently drift +// from the ingestor's truth. + +// LayoutContract is the top-level shape of layout.v1.json. +type LayoutContract struct { + Version string `json:"version"` + Tasks map[string]TaskLayout `json:"tasks"` +} + +// TaskLayout is one task's on-disk layout. +type TaskLayout struct { + Family string `json:"family"` // image | text | tabular + Manifest ManifestLayout `json:"manifest"` + PrimarySubdir *string `json:"primary_subdir"` // images | texts | sequences | null + Sidecars []SidecarSpec `json:"sidecars"` + RecordFormat *RecordFormat `json:"record_format"` // structured-text tasks only +} + +// ManifestLayout describes the task's manifest CSV. +type ManifestLayout struct { + Kind string `json:"kind"` // labels_csv | data_csv + RequiresFilenameColumn bool `json:"requires_filename_column"` + HasLabelColumn bool `json:"has_label_column"` +} + +// SidecarSpec is an extra per-row directory a file-bearing task needs beyond +// its primary subdir (object_detection's annotations/, semseg's masks/). +type SidecarSpec struct { + Subdir string `json:"subdir"` + Glob string `json:"glob"` + Required bool `json:"required"` + LinkColumn *string `json:"link_column"` // manifest column linking a row to its sidecar; null = paired by filename stem +} + +// RecordFormat is the structure inside each .txt for the structured text +// tasks. Fields are the ordered field names separated by Separator; MinFields +// is the fewest that must be present (embeddings accepts an optional trailing +// negative, so Fields=(anchor,positive,negative) with MinFields=2). Enforced +// is true only when a structural validator rejects a malformed file in-cluster +// (sentence_pair, embeddings); false marks a documented convention the +// ingestor does NOT reject (seq2seq, causal LM accept raw free text), so a +// mirror must not reject it either. +type RecordFormat struct { + Separator string `json:"separator"` + Fields []string `json:"fields"` + MinFields int `json:"min_fields"` + Enforced bool `json:"enforced"` +} + +// layoutContract is the parsed embedded contract. Parsed once at package init; +// a parse failure means the vendored JSON is broken (a build/vendoring bug CI +// catches via sync-schema.sh --check), so we fail loudly rather than limp on. +var layoutContract = mustLoadLayoutContract() + +func mustLoadLayoutContract() *LayoutContract { + var c LayoutContract + if err := json.Unmarshal(schema.LayoutV1Bytes, &c); err != nil { + panic(fmt.Sprintf("parsing embedded layout.v1.json: %v", err)) + } + return &c +} + +// LayoutFor returns the layout contract for a task category and whether it is +// present in the contract. +func LayoutFor(category string) (TaskLayout, bool) { + t, ok := layoutContract.Tasks[category] + return t, ok +} + +// RecordFormatFor returns the record format for a text category and whether it +// declares one. Tasks without a structured .txt shape (text_classification, +// token_classification, MLM) return false. +func RecordFormatFor(category string) (RecordFormat, bool) { + t, ok := layoutContract.Tasks[category] + if !ok || t.RecordFormat == nil { + return RecordFormat{}, false + } + return *t.RecordFormat, true +} + +// AllowedFieldCounts is the set of field counts a valid record may have — +// MinFields..len(Fields), inclusive. Mirrors the ingestor's +// TabSeparatedRecordValidator.ALLOWED_FIELD_COUNTS (sentence_pair: {2}; +// embeddings: {2, 3}). +func (rf RecordFormat) AllowedFieldCounts() []int { + var out []int + for n := rf.MinFields; n <= len(rf.Fields); n++ { + out = append(out, n) + } + return out +} + +// sepLabel renders the separator for an error message — a literal tab becomes +// "" so the message is readable in a terminal. +func (rf RecordFormat) sepLabel() string { + if rf.Separator == "\t" { + return "" + } + return rf.Separator +} + +// shape renders the canonical record shape, e.g. "text_atext_b" or +// "anchorpositivenegative". +func (rf RecordFormat) shape() string { + return strings.Join(rf.Fields, rf.sepLabel()) +} + +// countPhrase renders the allowed field-count clause: "exactly 2" for a single +// allowed count, "2 or 3" for a range. +func (rf RecordFormat) countPhrase() string { + counts := rf.AllowedFieldCounts() + if len(counts) == 1 { + return fmt.Sprintf("exactly %d", counts[0]) + } + parts := make([]string, len(counts)) + for i, n := range counts { + parts[i] = fmt.Sprintf("%d", n) + } + return strings.Join(parts, " or ") +} + +// ValidateTextRecord mirrors the ingestor's TabSeparatedRecordValidator +// per-file structural check for the ENFORCED record-format text tasks +// (sentence_pair_classification, embeddings): the file must be a single line +// of MinFields..len(Fields) non-empty separator-delimited fields. +// +// For unenforced formats (causal_language_modeling, seq2seq) it returns nil — +// the ingestor accepts raw free text for those, so a mirror must not reject it +// (RFC-0002 Principle 6). An empty / whitespace-only file also returns nil: the +// ingestor leaves that to its TextContentValidator (which warns), so rejecting +// it here would diverge. +func ValidateTextRecord(rf RecordFormat, content string) error { + if !rf.Enforced { + return nil + } + // Drop only surrounding blank lines / trailing newline — NOT interior + // separators, so a leading/trailing empty field is still caught below. + record := strings.Trim(content, "\r\n") + if strings.TrimSpace(record) == "" { + return nil + } + // One record per file: a surviving interior line break means several + // records were crammed in (or a field holds a newline) — ambiguous. + if strings.ContainsAny(record, "\r\n") { + return fmt.Errorf( + "expected a single %s record but the file spans multiple lines. "+ + "Put one %s per .txt", rf.shape(), rf.shape()) + } + parts := strings.Split(record, rf.Separator) + if !slices.Contains(rf.AllowedFieldCounts(), len(parts)) { + // Separator comes from the contract (sepLabel renders a tab as ""), + // so a future non-tab task isn't misdescribed as "tab-separated". + return fmt.Errorf( + "expected %s %s-separated fields (%s), found %d. "+ + "Separate each field with exactly one %s", + rf.countPhrase(), rf.sepLabel(), rf.shape(), len(parts), rf.sepLabel()) + } + for i, p := range parts { + if strings.TrimSpace(p) == "" { + return fmt.Errorf( + "field %d is empty — every field (%s) must be non-empty", + i+1, strings.Join(rf.Fields[:len(parts)], ", ")) + } + } + return nil +} diff --git a/internal/push/layout_contract_test.go b/internal/push/layout_contract_test.go new file mode 100644 index 00000000..0a5809ba --- /dev/null +++ b/internal/push/layout_contract_test.go @@ -0,0 +1,173 @@ +package push + +import "testing" + +// These tests pin the Go category registry as a VERIFIED MIRROR of the +// vendored layout contract (internal/schema/layout.v1.json), so category.go +// cannot silently drift from the ingestor's on-disk truth (RFC-0002 +// Principle 6). The contract itself is drift-checked against data-ingestors by +// scripts/sync-schema.sh, so this ties the registry transitively to upstream. + +// familyFromContract maps the contract's family string to the CLI Family enum. +func familyFromContract(t *testing.T, s string) Family { + t.Helper() + switch s { + case "image": + return FamilyImage + case "text": + return FamilyText + case "tabular": + return FamilyTabular + default: + t.Fatalf("unknown contract family %q", s) + return 0 + } +} + +// TestRegistryMirrorsLayoutContract: for every category the Go registry knows, +// the layout contract must agree on family and on the label-column fact, and +// vice versa (every contract task must be a known category). This is the +// single guard that keeps the hand-maintained registry honest against the +// machine-readable contract. +func TestRegistryMirrorsLayoutContract(t *testing.T) { + // Registry ⊆ contract, with agreeing facts. + for _, c := range categoryRegistry { + layout, ok := LayoutFor(c.ID) + if !ok { + t.Errorf("category %q is in the registry but missing from layout.v1.json", c.ID) + continue + } + if want := familyFromContract(t, layout.Family); c.Family != want { + t.Errorf("%s: registry Family = %d, contract says %q (%d)", c.ID, c.Family, layout.Family, want) + } + // SelfSupervised (no label question) is the inverse of the contract's + // has_label_column, for EVERY category — image/tabular carry a label + // and are not self-supervised; the self-supervised text tasks carry + // none. This is the fact spec.buildText + the interactive label prompt + // both key off, so pinning it here catches a mis-set flag. + if c.SelfSupervised == layout.Manifest.HasLabelColumn { + t.Errorf("%s: registry SelfSupervised = %v but contract has_label_column = %v (must be opposite)", + c.ID, c.SelfSupervised, layout.Manifest.HasLabelColumn) + } + } + + // Contract ⊆ registry: no task in the contract is unknown to the CLI. + for id := range layoutContract.Tasks { + if !IsKnown(id) { + t.Errorf("layout.v1.json task %q is not a known CLI category", id) + } + } +} + +// TestTextSidecarDirMirrorsContract: TextSidecarDir must return exactly the +// contract's primary_subdir for every text task — the directory the CLI stages +// into has to be the one the ingestor reads (texts/ for every text task but +// MLM, which uses sequences/). +func TestTextSidecarDirMirrorsContract(t *testing.T) { + for _, c := range categoryRegistry { + if c.Family != FamilyText { + continue + } + layout, ok := LayoutFor(c.ID) + if !ok || layout.PrimarySubdir == nil { + t.Fatalf("%s: text task missing a primary_subdir in the contract", c.ID) + } + if got := TextSidecarDir(c.ID); got != *layout.PrimarySubdir { + t.Errorf("%s: TextSidecarDir = %q, contract primary_subdir = %q", c.ID, got, *layout.PrimarySubdir) + } + } +} + +// TestRecordFormatFor_Contract pins the record-format facts the CLI enforces +// against the contract: the two enforced structured tasks and the two +// unenforced conventions, plus the derived allowed field counts. +func TestRecordFormatFor_Contract(t *testing.T) { + cases := []struct { + category string + wantPresent bool + wantEnforced bool + wantCounts []int + }{ + {"sentence_pair_classification", true, true, []int{2}}, + {"embeddings", true, true, []int{2, 3}}, + {"seq2seq", true, false, []int{1, 2}}, + {"causal_language_modeling", true, false, []int{1, 2}}, + {"token_classification", false, false, nil}, // no structured record + {"text_classification", false, false, nil}, + {"image_classification", false, false, nil}, + } + for _, tc := range cases { + t.Run(tc.category, func(t *testing.T) { + rf, ok := RecordFormatFor(tc.category) + if ok != tc.wantPresent { + t.Fatalf("RecordFormatFor(%s) present = %v, want %v", tc.category, ok, tc.wantPresent) + } + if !ok { + return + } + if rf.Enforced != tc.wantEnforced { + t.Errorf("%s: Enforced = %v, want %v", tc.category, rf.Enforced, tc.wantEnforced) + } + got := rf.AllowedFieldCounts() + if len(got) != len(tc.wantCounts) { + t.Fatalf("%s: AllowedFieldCounts = %v, want %v", tc.category, got, tc.wantCounts) + } + for i := range got { + if got[i] != tc.wantCounts[i] { + t.Errorf("%s: AllowedFieldCounts = %v, want %v", tc.category, got, tc.wantCounts) + } + } + }) + } +} + +// TestValidateTextRecord mirrors the ingestor's TabSeparatedRecordValidator +// cases: enforced tasks reject the wrong field count / empty fields / multiple +// lines, accept a well-formed record, and never reject on an unenforced format. +func TestValidateTextRecord(t *testing.T) { + sp, _ := RecordFormatFor("sentence_pair_classification") + emb, _ := RecordFormatFor("embeddings") + s2s, _ := RecordFormatFor("seq2seq") + + // Well-formed records pass. + if err := ValidateTextRecord(sp, "left side\tright side"); err != nil { + t.Errorf("valid sentence pair rejected: %v", err) + } + if err := ValidateTextRecord(emb, "anchor\tpositive"); err != nil { + t.Errorf("valid embeddings pair rejected: %v", err) + } + if err := ValidateTextRecord(emb, "anchor\tpositive\tnegative"); err != nil { + t.Errorf("valid embeddings triplet rejected: %v", err) + } + // A trailing newline is stripped, not an error. + if err := ValidateTextRecord(sp, "left\tright\n"); err != nil { + t.Errorf("trailing newline should be tolerated: %v", err) + } + + // Malformed records fail. + if err := ValidateTextRecord(sp, "no tab here"); err == nil { + t.Error("sentence pair with 1 field should fail") + } + if err := ValidateTextRecord(sp, "a\tb\tc"); err == nil { + t.Error("sentence pair with 3 fields should fail") + } + if err := ValidateTextRecord(emb, "only one"); err == nil { + t.Error("embeddings with 1 field should fail") + } + if err := ValidateTextRecord(sp, "left\t"); err == nil { + t.Error("empty trailing field should fail") + } + if err := ValidateTextRecord(sp, "l1\tr1\nl2\tr2"); err == nil { + t.Error("multi-line record should fail") + } + + // Unenforced format never rejects, even malformed-looking content. + if err := ValidateTextRecord(s2s, "just raw text no tab"); err != nil { + t.Errorf("unenforced seq2seq should accept raw text: %v", err) + } + // An empty / whitespace-only file is the TextContentValidator's job, not + // this structural check — it must pass here (no double reporting). + if err := ValidateTextRecord(sp, " \n"); err != nil { + t.Errorf("empty file should be tolerated by the structural check: %v", err) + } +} diff --git a/internal/push/preflight.go b/internal/push/preflight.go index 4b884d04..bbdb617f 100644 --- a/internal/push/preflight.go +++ b/internal/push/preflight.go @@ -31,6 +31,47 @@ import ( // utf8BOM is the byte-order mark Excel's "CSV UTF-8" export prepends. var utf8BOM = []byte{0xEF, 0xBB, 0xBF} +// openCSVReader opens path for row-walking with any UTF-8 BOM stripped, the one +// idiom the pandas-backed checks share (cli#71): pandas strips the BOM even +// under encoding="utf-8", so a BOM'd file must read as if it had none or the +// CLI would reject what the cluster accepts. FieldsPerRecord is -1 so a ragged +// row is a per-row concern, not an abort. The caller closes the returned +// Closer. A caller that must read the rows pandas tolerates (an unescaped +// quote) sets r.LazyQuotes = true before its first Read. +func openCSVReader(path string) (*csv.Reader, io.Closer, error) { + f, err := os.Open(path) + if err != nil { + return nil, nil, err + } + br := bufio.NewReader(f) + if head, _ := br.Peek(3); bytes.Equal(head, utf8BOM) { + _, _ = br.Discard(3) + } + r := csv.NewReader(br) + r.FieldsPerRecord = -1 + return r, f, nil +} + +// matchColumnIndex returns the index of the header column matching want — an +// exact match first, then case-insensitively with surrounding whitespace +// stripped (the ingestor's resolve_column / _match_column rule). Returns -1 +// when absent. Shared by the label-column and filename-column checks so the +// ingestor's single resolve rule has a single Go copy. +func matchColumnIndex(header []string, want string) int { + for i, c := range header { + if c == want { + return i + } + } + wl := strings.ToLower(strings.TrimSpace(want)) + for i, c := range header { + if strings.ToLower(strings.TrimSpace(c)) == wl { + return i + } + } + return -1 +} + // HasBOM reports whether the file starts with a UTF-8 BOM. func HasBOM(path string) (bool, error) { f, err := os.Open(path) @@ -56,16 +97,11 @@ func HasBOM(path string) (bool, error) { // accepts. The one in-cluster path that does NOT strip it is the tabular // schema probe — see CheckTabularBOM. func ReadCSVHeader(path string) ([]string, error) { - f, err := os.Open(path) + r, closer, err := openCSVReader(path) if err != nil { return nil, err } - defer func() { _ = f.Close() }() - br := bufio.NewReader(f) - if head, _ := br.Peek(3); bytes.Equal(head, utf8BOM) { - _, _ = br.Discard(3) - } - r := csv.NewReader(br) + defer func() { _ = closer.Close() }() header, err := r.Read() if err != nil { if errors.Is(err, io.EOF) { @@ -110,16 +146,8 @@ func CheckTabularBOM(path string) error { // must stay this loose or the CLI would reject datasets the cluster // accepts). func CheckLabelColumn(header []string, labelColumn, csvName string) error { - for _, c := range header { - if c == labelColumn { - return nil - } - } - want := strings.ToLower(strings.TrimSpace(labelColumn)) - for _, c := range header { - if strings.ToLower(strings.TrimSpace(c)) == want { - return nil - } + if matchColumnIndex(header, labelColumn) >= 0 { + return nil } return fmt.Errorf( "label column %q isn't in %s's header (columns: %s). Pass --label-column with one of "+ @@ -158,17 +186,11 @@ func CheckDuplicateHeaders(header []string, csvName string) error { // category): a header-only CSV has zero ingestable records and is // rejected in-cluster before any table is created. func CheckHasDataRows(path string) error { - f, err := os.Open(path) + r, closer, err := openCSVReader(path) if err != nil { return fmt.Errorf("reading %s: %w", filepath.Base(path), err) } - defer func() { _ = f.Close() }() - br := bufio.NewReader(f) - if head, _ := br.Peek(3); bytes.Equal(head, utf8BOM) { - _, _ = br.Discard(3) - } - r := csv.NewReader(br) - r.FieldsPerRecord = -1 // row-shape problems are someone else's diagnostic + defer func() { _ = closer.Close() }() if _, err := r.Read(); err != nil { if errors.Is(err, io.EOF) { return fmt.Errorf("%s is empty — add a header and at least one data row, then re-run", filepath.Base(path)) @@ -275,17 +297,11 @@ func ValidateImages(images []string, expectedW, expectedH, minW, minH int) error // filenameColumn is the CSV's first column (the ingestor reads filenames // positionally from the id column of labels.csv). func CrossCheckLabels(csvPath string, images []string, extension string) (missing []string, orphans []string, err error) { - f, err := os.Open(csvPath) + r, closer, err := openCSVReader(csvPath) if err != nil { return nil, nil, fmt.Errorf("reading %s: %w", filepath.Base(csvPath), err) } - defer func() { _ = f.Close() }() - br := bufio.NewReader(f) - if head, _ := br.Peek(3); bytes.Equal(head, utf8BOM) { - _, _ = br.Discard(3) - } - r := csv.NewReader(br) - r.FieldsPerRecord = -1 + defer func() { _ = closer.Close() }() present := make(map[string]bool, len(images)) for _, img := range images { @@ -453,17 +469,11 @@ func ReadLabelValues(csvPath, labelColumn string, dropNASentinels, collapseNumer // scan, this reads the whole column to build the full class set + row count — // one scan now backs both the diversity verdict and the value-level preview. func readLabelColumnValues(csvPath, labelColumn string, dropNASentinels, collapseNumeric bool) LabelReadValues { - f, err := os.Open(csvPath) + r, closer, err := openCSVReader(csvPath) if err != nil { return LabelReadValues{} // Found=false: unreadable file is another check's diagnostic } - defer func() { _ = f.Close() }() - br := bufio.NewReader(f) - if head, _ := br.Peek(3); bytes.Equal(head, utf8BOM) { - _, _ = br.Discard(3) - } - r := csv.NewReader(br) - r.FieldsPerRecord = -1 + defer func() { _ = closer.Close() }() header, err := r.Read() if err != nil { return LabelReadValues{} @@ -797,14 +807,28 @@ func PreflightDataset(spec SpecArgs, layout *LocalLayout) (notes []string, probl if err := CheckDuplicateHeaders(header, "labels.csv"); err != nil { return nil, dataProblem(err) } - if spec.Category == "text_classification" { + // Every SUPERVISED text task carries a label column the ingestor + // requires present — text_classification & sentence_pair_classification + // via LabelColumnValidator, token_classification via BIOLabelValidator — + // so preview the header for all of them. Gating on !SelfSupervisedText + // mirrors buildText's label emission, so a typo'd --label-column fails + // locally (exit 2) instead of after the full upload. The self-supervised + // tasks (MLM, CLM, seq2seq, embeddings) carry no label. + if !SelfSupervisedText(spec.Category) { if err := CheckLabelColumn(header, spec.LabelColumn, "labels.csv"); err != nil { return nil, &PreflightProblem{Err: err, BadFlag: true} } - // Text labels are read untyped (like image), so no NA drop and - // no numeric collapse. - if err := CheckLabelDiversity(layout.LabelsCSV, spec.LabelColumn, false, false); err != nil { - return nil, dataProblem(err) + // LabelDiversityValidator is wired only for the is_classification + // text tasks (text_classification, sentence_pair_classification), NOT + // token_classification — its BIO tag sequences aren't class labels, + // so the ingestor runs BIOLabelValidator instead and never checks + // label diversity. Mirror that exactly (Principle 6): gate on + // IsClassification. Text labels are read untyped (like image), so no + // NA drop and no numeric collapse. + if IsClassification(spec.Category) { + if err := CheckLabelDiversity(layout.LabelsCSV, spec.LabelColumn, false, false); err != nil { + return nil, dataProblem(err) + } } } } diff --git a/internal/push/preflight_test.go b/internal/push/preflight_test.go index 38ff928f..b3bc11c7 100644 --- a/internal/push/preflight_test.go +++ b/internal/push/preflight_test.go @@ -301,3 +301,50 @@ func TestCheckLabelDiversitySchemaTypeDispatch(t *testing.T) { t.Error("FLOAT label should collapse '1'/'1.0' and be rejected") } } + +// TestPreflightDataset_TextLabelParity locks the text-family label preflight to +// the ingestor's wiring across ALL supervised text tasks (not just +// text_classification, which is how the gate drifted when #182 wired the rest): +// - a missing label column fails locally (BadFlag) for every supervised text +// task — LabelColumnValidator for text/sentence_pair, BIOLabelValidator for +// token_classification — instead of uploading and failing in-cluster; +// - a single-class label is rejected for the is_classification text tasks +// (LabelDiversityValidator), but token_classification (BIO tag sequences, +// is_classification=false) must NOT trigger diversity — the ingestor never +// runs it, so neither may the CLI. +func TestPreflightDataset_TextLabelParity(t *testing.T) { + writeLayout := func(t *testing.T, content string) *LocalLayout { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "labels.csv") + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return &LocalLayout{Root: dir, LabelsCSV: p} + } + + for _, cat := range []string{"text_classification", "sentence_pair_classification", "token_classification"} { + layout := writeLayout(t, "filename,text\na.txt,foo\nb.txt,bar\n") + _, problem := PreflightDataset(SpecArgs{Category: cat, LabelColumn: "label"}, layout) + if problem == nil { + t.Errorf("%s: a missing label column should fail preflight before upload", cat) + continue + } + if !problem.BadFlag { + t.Errorf("%s: a missing label column should be a BadFlag (settings) problem, got %v", cat, problem.Err) + } + } + + single := "filename,label\na.txt,x\nb.txt,x\n" + for _, cat := range []string{"text_classification", "sentence_pair_classification"} { + layout := writeLayout(t, single) + if _, problem := PreflightDataset(SpecArgs{Category: cat, LabelColumn: "label"}, layout); problem == nil { + t.Errorf("%s: a single-class label should be rejected (classification needs >=2 classes)", cat) + } + } + layout := writeLayout(t, single) + if _, problem := PreflightDataset(SpecArgs{Category: "token_classification", LabelColumn: "label"}, layout); problem != nil { + t.Errorf("token_classification: a single-value label must NOT trigger the diversity check "+ + "(BIO labels aren't class labels; the ingestor runs BIOLabelValidator, not LabelDiversity): %v", problem.Err) + } +} diff --git a/internal/push/preview_test.go b/internal/push/preview_test.go index dba65827..ae6bcd1c 100644 --- a/internal/push/preview_test.go +++ b/internal/push/preview_test.go @@ -293,12 +293,16 @@ func TestCategoriesByFamily(t *testing.T) { } func TestSelfSupervisedText(t *testing.T) { - for _, id := range []string{"masked_language_modeling", "causal_language_modeling"} { + // The self-supervised text tasks have no label column (MLM/CLM predict from + // the text itself; seq2seq/embeddings derive their target from the record + // structure), so the interactive flow skips the label question for them. + for _, id := range []string{"masked_language_modeling", "causal_language_modeling", "seq2seq", "embeddings"} { if !SelfSupervisedText(id) { t.Errorf("%s should be self-supervised", id) } } - for _, id := range []string{"text_classification", "tabular_regression", "image_classification"} { + // The SUPERVISED text tasks carry a label column and must still be asked. + for _, id := range []string{"text_classification", "token_classification", "sentence_pair_classification", "tabular_regression", "image_classification"} { if SelfSupervisedText(id) { t.Errorf("%s should not be self-supervised", id) } diff --git a/internal/push/spec.go b/internal/push/spec.go index 73dafcd6..6d7f3f6d 100644 --- a/internal/push/spec.go +++ b/internal/push/spec.go @@ -268,15 +268,22 @@ func (a SpecArgs) Build() map[string]any { } // buildText fills in the text-family fields: the text-file sidecar -// directory (texts/ for text_classification, sequences/ for -// masked_language_modeling) and the label. masked_language_modeling -// has NO label (the schema doesn't require one for it). +// directory (texts/ for every text task except masked_language_modeling, +// which uses sequences/) and, for the SUPERVISED text tasks, the label. +// +// The self-supervised text tasks (masked/causal language modeling, seq2seq, +// embeddings) carry NO label column — their target is derived from the text +// itself, and the schema does not require `label` for them. The supervised +// ones (text_classification, token_classification, sentence_pair_classification) +// do, so the label is emitted for exactly those — driven by the registry's +// SelfSupervised flag (mirrored against the layout contract's has_label_column) +// rather than a hardcoded id, so a new task can't be wired without deciding it. func (a SpecArgs) buildText(spec map[string]any, prefix string) { dir := TextSidecarDir(a.Category) // Trailing slash matches the directory-glob convention the // ingestor uses for sidecar dirs. spec[dir] = path.Join(prefix, dir) + "/" - if a.Category == "text_classification" { + if !SelfSupervisedText(a.Category) { spec["label"] = a.LabelColumn } } diff --git a/internal/push/text.go b/internal/push/text.go index 4d9048b4..2c7a9b34 100644 --- a/internal/push/text.go +++ b/internal/push/text.go @@ -3,6 +3,7 @@ package push import ( "errors" "fmt" + "io" "os" "path/filepath" "strings" @@ -84,6 +85,26 @@ func DiscoverText(category, rootDir string) (*LocalLayout, error) { layout.Sidecars[dirName] = files layout.TotalBytes += sidecarBytes + // Structured-text tasks whose .txt shape the ingestor ENFORCES + // (sentence_pair_classification: text_atext_b; embeddings: + // anchorpositive[negative]) get the same per-file structural + // check here, so a malformed layout fails locally with a clear message + // instead of after the full stage. The rule comes from the vendored + // layout contract, not hardcoded — the CLI mirrors the ingestor's + // TabSeparatedRecordValidator (RFC-0002 Principle 6). Unenforced formats + // (seq2seq, causal LM) accept raw text and are not checked. + // + // The check is scoped to the files the manifest actually references, NOT + // every .txt in the dir: the ingestor's validator walks labels.csv rows and + // only opens the file each row names, so an unreferenced stray .txt (a + // README, a scratch draft) must not fail discovery — the ingestor would + // accept the dataset. + if rf, ok := RecordFormatFor(category); ok && rf.Enforced { + if err := validateTextRecords(labelsPath, dirName, files, rf); err != nil { + return nil, err + } + } + if layout.TotalBytes > MaxTotalBytes { return nil, fmt.Errorf( "dataset is %s, exceeds v0.1 cap of %s. For larger datasets, the "+ @@ -93,6 +114,122 @@ func DiscoverText(category, rootDir string) (*LocalLayout, error) { return layout, nil } +// validateTextRecords runs the enforced record-format check over the +// manifest-referenced text files in dirName, mirroring the ingestor's per-file +// TabSeparatedRecordValidator: it walks labels.csv rows (not the directory) and +// checks the file each row names. Only files a row references are checked — the +// ingestor never opens a file no row references, so validating a stray +// unreferenced .txt would reject a layout the ingestor accepts (RFC-0002 +// Principle 6). The first malformed file fails discovery with a message naming +// the offending file (relative to the dataset root, e.g. "texts/bad.txt"), so +// the fix is obvious without reaching the cluster. +// +// Each manifest value is matched against the files actually discovered on disk, +// not a reconstructed ".txt": the ingestor appends the CONFIGURED +// extension (.txt or .text — file_options.extension), so a row "a" must match +// texts/a.text when that is what is on disk. Matching is case-insensitive on +// both the basename and the stem so "A.txt" in the manifest still resolves to +// a.txt on disk (fail-open otherwise). +func validateTextRecords(csvPath, dirName string, files []string, rf RecordFormat) error { + referenced, err := manifestReferencedTextNames(csvPath) + if err != nil { + return err + } + + // Index the discovered files by lowercased basename and by lowercased stem, + // so a manifest value resolves to the file the ingestor would open whether + // or not it carries the extension, and regardless of case. + byBase := make(map[string]string, len(files)) + byStem := make(map[string]string, len(files)) + for _, f := range files { + base := filepath.Base(f) + byBase[strings.ToLower(base)] = f + stem := strings.TrimSuffix(base, filepath.Ext(base)) + byStem[strings.ToLower(stem)] = f + } + + for name := range referenced { + // Mirror file_transfer._has_extension: a value that already ends in a + // known extension names the file directly; otherwise the ingestor + // appends its configured extension, so match on the stem. + var path string + if hasKnownExtension(name) { + path = byBase[strings.ToLower(name)] + } else { + path = byStem[strings.ToLower(name)] + } + if path == "" { + continue // manifest names a file not on disk — a missing-file check's job, not ours + } + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("reading %s: %w", filepath.Join(dirName, filepath.Base(path)), err) + } + if verr := ValidateTextRecord(rf, string(content)); verr != nil { + return fmt.Errorf("%s: %w", filepath.Join(dirName, filepath.Base(path)), verr) + } + } + return nil +} + +// manifestReferencedTextNames returns the set of raw text-file values the +// manifest (labels.csv) references — the "filename" column, trimmed, dropping +// blanks — mirroring the ingestor's TabSeparatedRecordValidator manifest walk. +// The values are returned unresolved (no extension appended); the caller +// matches them against the discovered files, since only there does it know +// which extension is actually on disk. +// +// The filename column is REQUIRED: the ingestor's validator rejects a manifest +// without it ("Missing required column: filename"), so — for the enforced tasks +// that call this — surface that locally rather than validating nothing (which +// would fail-open post-upload). An empty CSV yields an empty set (its own +// emptiness is another check's diagnostic). The CSV is read with LazyQuotes so +// a row pandas tolerates (an unescaped quote) is read here too, not silently +// dropped — its filename would otherwise never be validated. +func manifestReferencedTextNames(csvPath string) (map[string]struct{}, error) { + r, closer, err := openCSVReader(csvPath) + if err != nil { + return nil, fmt.Errorf("reading labels.csv: %w", err) + } + defer func() { _ = closer.Close() }() + r.LazyQuotes = true // read the rows pandas would, don't drop them + + header, err := r.Read() + if err != nil { + if errors.Is(err, io.EOF) { + return map[string]struct{}{}, nil // empty CSV — another check's diagnostic + } + return nil, fmt.Errorf("reading labels.csv: %w", err) + } + col := matchColumnIndex(header, "filename") + if col < 0 { + return nil, fmt.Errorf( + "labels.csv has no \"filename\" column (columns: %s) — the ingestor matches each "+ + "row to its text file by that column and rejects a manifest without it. "+ + "Add a filename column and re-run.", + strings.Join(header, ", ")) + } + referenced := map[string]struct{}{} + for { + rec, err := r.Read() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + continue // a row even LazyQuotes can't read is another check's diagnostic + } + if col >= len(rec) { + continue + } + name := strings.TrimSpace(rec[col]) + if name == "" { + continue + } + referenced[name] = struct{}{} + } + return referenced, nil +} + // discoverSidecarFiles walks / (non-recursive) for files // whose extension is in exts, rejecting symlinks and enforcing the // single-file cap. Returns the absolute paths + their total size. A diff --git a/internal/push/text_test.go b/internal/push/text_test.go index 9556ce62..4c228f57 100644 --- a/internal/push/text_test.go +++ b/internal/push/text_test.go @@ -107,6 +107,241 @@ func TestDiscoverText_MissingSidecarDir(t *testing.T) { } } +// mkStructuredTextDir builds a text dataset whose texts/ files carry the given +// contents (filename → body). labels.csv lists each file; hasLabel adds a +// label column (supervised tasks) so the CSV mirrors what the ingestor reads. +func mkStructuredTextDir(t *testing.T, hasLabel bool, files map[string]string) string { + t.Helper() + dir := t.TempDir() + header := "filename\n" + if hasLabel { + header = "filename,label\n" + } + csv := header + sub := filepath.Join(dir, "texts") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + for name, body := range files { + if hasLabel { + csv += name + ",x\n" + } else { + csv += name + "\n" + } + if err := os.WriteFile(filepath.Join(sub, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + writeFile(t, dir, "labels.csv", csv) + return dir +} + +// TestDiscoverText_AllPhase4Tasks: each of the 5 newly-wired text tasks +// discovers its texts/ layout and stages labels.csv + the text files, with a +// valid fixture per the layout contract (enforced formats get a well-formed +// record; unenforced ones get raw text). +func TestDiscoverText_AllPhase4Tasks(t *testing.T) { + cases := []struct { + category string + hasLabel bool + body string + }{ + {"token_classification", true, "the\tDET\ncat\tNOUN"}, // per-token; no enforced record_format + {"sentence_pair_classification", true, "a rose is red\tit is a flower"}, // text_atext_b (enforced) + {"causal_language_modeling", false, "just some raw pretraining text"}, // unenforced + {"seq2seq", false, "bonjour le monde\thello world"}, // sourcetarget (unenforced) + {"embeddings", false, "query\tpositive doc\thard negative"}, // anchorpositivenegative (enforced) + } + for _, tc := range cases { + t.Run(tc.category, func(t *testing.T) { + dir := mkStructuredTextDir(t, tc.hasLabel, map[string]string{"a.txt": tc.body}) + layout, err := DiscoverText(tc.category, dir) + if err != nil { + t.Fatalf("DiscoverText(%s): %v", tc.category, err) + } + if len(layout.Sidecars["texts"]) != 1 { + t.Errorf("texts files = %d, want 1", len(layout.Sidecars["texts"])) + } + if got := layout.FileCount(); got != 2 { // labels.csv + 1 text + t.Errorf("FileCount = %d, want 2", got) + } + }) + } +} + +// TestDiscoverText_EnforcedRecordFormat_Reject: the ENFORCED formats +// (sentence_pair_classification, embeddings) reject a malformed .txt at +// discovery, with a message that names the file and the expected shape — +// mirroring the ingestor's TabSeparatedRecordValidator. The UNENFORCED formats +// (seq2seq, causal LM) accept the same raw content, so a mirror must not reject. +func TestDiscoverText_EnforcedRecordFormat_Reject(t *testing.T) { + // A single field, no tab: malformed for the enforced tasks. + rawNoTab := map[string]string{"bad.txt": "one blob of prose with no tab"} + + for _, category := range []string{"sentence_pair_classification", "embeddings"} { + t.Run(category+"_rejects", func(t *testing.T) { + hasLabel := !SelfSupervisedText(category) + dir := mkStructuredTextDir(t, hasLabel, rawNoTab) + _, err := DiscoverText(category, dir) + if err == nil { + t.Fatalf("DiscoverText(%s) accepted a malformed record", category) + } + // The separator label comes from the contract (sepLabel renders a + // tab as ""), not a hardcoded "tab-separated" literal. + for _, want := range []string{"bad.txt", "-separated fields"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error missing %q: %v", want, err) + } + } + }) + } + + // The unenforced tasks accept the very same raw file. + for _, category := range []string{"seq2seq", "causal_language_modeling"} { + t.Run(category+"_accepts_raw", func(t *testing.T) { + dir := mkStructuredTextDir(t, false, rawNoTab) + if _, err := DiscoverText(category, dir); err != nil { + t.Errorf("DiscoverText(%s) rejected raw text it should accept: %v", category, err) + } + }) + } +} + +// TestDiscoverText_EnforcedRecordFormat_IgnoresUnreferenced: the enforced +// record-format check runs only over the files labels.csv references, mirroring +// the ingestor's manifest walk (TabSeparatedRecordValidator iterates the CSV +// rows, not the directory). A stray unreferenced .txt in texts/ — a README, a +// scratch draft with no tab — must NOT fail discovery: the ingestor never opens +// a file no row names, so rejecting it would block a layout the cluster accepts +// (RFC-0002 Principle 6). +func TestDiscoverText_EnforcedRecordFormat_IgnoresUnreferenced(t *testing.T) { + for _, category := range []string{"sentence_pair_classification", "embeddings"} { + t.Run(category, func(t *testing.T) { + hasLabel := !SelfSupervisedText(category) + // a.txt is a well-formed 2-field record AND is referenced by + // labels.csv; notes.txt is prose with no tab and is NOT referenced. + dir := mkStructuredTextDir(t, hasLabel, map[string]string{"a.txt": "left side\tright side"}) + stray := filepath.Join(dir, "texts", "notes.txt") + if err := os.WriteFile(stray, []byte("just some prose with no tab"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := DiscoverText(category, dir); err != nil { + t.Errorf("DiscoverText(%s) rejected a dataset with an unreferenced stray .txt "+ + "the ingestor would accept: %v", category, err) + } + }) + } +} + +// TestDiscoverText_SentencePair_WrongFieldCount: sentence_pair requires exactly +// 2 fields — a 3-field record is rejected, whereas embeddings accepts 2 or 3. +func TestDiscoverText_SentencePair_WrongFieldCount(t *testing.T) { + three := map[string]string{"a.txt": "one\ttwo\tthree"} + + dir := mkStructuredTextDir(t, true, three) + if _, err := DiscoverText("sentence_pair_classification", dir); err == nil { + t.Error("sentence_pair_classification should reject a 3-field record") + } + + dir2 := mkStructuredTextDir(t, false, three) + if _, err := DiscoverText("embeddings", dir2); err != nil { + t.Errorf("embeddings should accept a 3-field triplet: %v", err) + } +} + +// TestDiscoverText_ConfiguredExtension: the ingestor appends the CONFIGURED +// extension (.txt OR .text — file_options.extension), so the enforced check +// must match a manifest value against the file actually on disk, not a +// reconstructed ".txt". A row "a" resolves to texts/a.text when that is +// what exists — a malformed a.text is rejected, a well-formed one passes. +func TestDiscoverText_ConfiguredExtension(t *testing.T) { + mk := func(t *testing.T, body string) string { + t.Helper() + dir := t.TempDir() + writeFile(t, dir, "labels.csv", "filename\na\n") // embeddings: no label; value carries no extension + sub := filepath.Join(dir, "texts") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "a.text"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return dir + } + + if _, err := DiscoverText("embeddings", mk(t, "one blob no tab")); err == nil { + t.Fatal("malformed a.text should be rejected via the configured .text extension") + } else if !strings.Contains(err.Error(), "a.text") { + t.Errorf("error should name the on-disk file a.text: %v", err) + } + + if _, err := DiscoverText("embeddings", mk(t, "anchor\tpositive")); err != nil { + t.Errorf("well-formed a.text rejected: %v", err) + } +} + +// TestDiscoverText_MissingFilenameColumn: the ingestor's validator rejects a +// manifest with no filename column ("Missing required column: filename"); the +// enforced check surfaces that locally instead of validating nothing (which +// would fail-open post-upload). +func TestDiscoverText_MissingFilenameColumn(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "labels.csv", "id\nrow1\n") // no filename column + sub := filepath.Join(dir, "texts") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "a.txt"), []byte("anchor\tpositive"), 0o644); err != nil { + t.Fatal(err) + } + _, err := DiscoverText("embeddings", dir) + if err == nil { + t.Fatal("a manifest with no filename column should be rejected locally") + } + if !strings.Contains(err.Error(), "filename") { + t.Errorf("error should name the missing filename column: %v", err) + } +} + +// TestDiscoverText_CaseMismatchedBasename: a manifest value "A.txt" must resolve +// to the on-disk a.txt case-insensitively — otherwise the file goes unchecked +// (fail-open). The malformed a.txt is therefore still rejected. +func TestDiscoverText_CaseMismatchedBasename(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "labels.csv", "filename,label\nA.txt,x\n") + sub := filepath.Join(dir, "texts") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "a.txt"), []byte("one blob no tab"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := DiscoverText("sentence_pair_classification", dir); err == nil { + t.Fatal("case-mismatched manifest value A.txt should still match a.txt and reject the malformed file") + } +} + +// TestDiscoverText_TolerantManifestRow: a row Go's strict csv.Reader rejects but +// pandas tolerates (an unescaped quote) must still be read — LazyQuotes — so its +// filename is validated, not silently dropped. Here the tolerated row names a +// malformed a.txt, which must be rejected; without LazyQuotes the row (and its +// file) would be skipped and discovery would fail-open. +func TestDiscoverText_TolerantManifestRow(t *testing.T) { + dir := t.TempDir() + // The unescaped " in the label field trips Go's strict reader; pandas reads it. + writeFile(t, dir, "labels.csv", "filename,label\na.txt,he said \"hi\"\n") + sub := filepath.Join(dir, "texts") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "a.txt"), []byte("one blob no tab"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := DiscoverText("sentence_pair_classification", dir); err == nil { + t.Fatal("a pandas-tolerable row must be read (LazyQuotes) so its file is validated") + } +} + // TestBuild_Text_PassesSchema: the text Build branch emits the right // sidecar field (texts vs sequences), a label for text_classification // but NOT for masked_language_modeling, never an images field, and a @@ -149,4 +384,26 @@ func TestBuild_Text_PassesSchema(t *testing.T) { check("masked_language_modeling", SpecArgs{ Table: "t_mlm", Category: "masked_language_modeling", Intent: "train", }, "sequences", false) + + // Phase 4: supervised text tasks emit a label under texts/; the + // self-supervised ones emit none. Each must still be schema-valid. + check("token_classification", SpecArgs{ + Table: "t_tok", Category: "token_classification", Intent: "train", LabelColumn: "label", + }, "texts", true) + + check("sentence_pair_classification", SpecArgs{ + Table: "t_sp", Category: "sentence_pair_classification", Intent: "train", LabelColumn: "label", + }, "texts", true) + + check("causal_language_modeling", SpecArgs{ + Table: "t_clm", Category: "causal_language_modeling", Intent: "train", + }, "texts", false) + + check("seq2seq", SpecArgs{ + Table: "t_s2s", Category: "seq2seq", Intent: "train", + }, "texts", false) + + check("embeddings", SpecArgs{ + Table: "t_emb", Category: "embeddings", Intent: "train", + }, "texts", false) } diff --git a/internal/schema/embed.go b/internal/schema/embed.go index 9fa8f871..37229fad 100644 --- a/internal/schema/embed.go +++ b/internal/schema/embed.go @@ -28,3 +28,18 @@ import _ "embed" // //go:embed ingest.v1.json var V1Bytes []byte + +// LayoutV1Bytes is the raw JSON of the per-task dataset-layout contract +// (layout.v1.json), vendored from tracebloc/data-ingestors at build time via +// scripts/sync-schema.sh (data-ingestors#347/#353). +// +// The ingestor is the source of truth for what a task's local dataset looks +// like on disk — the manifest CSV, whether it carries a label column, the +// primary file subdir, extra sidecar dirs, and the in-.txt record format for +// the structured text tasks. Embedding it here lets the CLI's discovery + +// staging be a VERIFIED MIRROR of that contract (RFC-0002 Principle 6) rather +// than re-implementing the layout rules in Go — drift is caught at build time +// by the same sync-schema.sh --check the ingest schema uses. +// +//go:embed layout.v1.json +var LayoutV1Bytes []byte diff --git a/internal/schema/layout.v1.json b/internal/schema/layout.v1.json new file mode 100644 index 00000000..2ecdc199 --- /dev/null +++ b/internal/schema/layout.v1.json @@ -0,0 +1,217 @@ +{ + "tasks": { + "causal_language_modeling": { + "family": "text", + "manifest": { + "has_label_column": false, + "kind": "labels_csv", + "requires_filename_column": true + }, + "primary_subdir": "texts", + "record_format": { + "enforced": false, + "fields": [ + "prompt", + "completion" + ], + "min_fields": 1, + "separator": "\t" + }, + "sidecars": [] + }, + "embeddings": { + "family": "text", + "manifest": { + "has_label_column": false, + "kind": "labels_csv", + "requires_filename_column": true + }, + "primary_subdir": "texts", + "record_format": { + "enforced": true, + "fields": [ + "anchor", + "positive", + "negative" + ], + "min_fields": 2, + "separator": "\t" + }, + "sidecars": [] + }, + "image_classification": { + "family": "image", + "manifest": { + "has_label_column": true, + "kind": "labels_csv", + "requires_filename_column": true + }, + "primary_subdir": "images", + "record_format": null, + "sidecars": [] + }, + "keypoint_detection": { + "family": "image", + "manifest": { + "has_label_column": true, + "kind": "labels_csv", + "requires_filename_column": true + }, + "primary_subdir": "images", + "record_format": null, + "sidecars": [] + }, + "masked_language_modeling": { + "family": "text", + "manifest": { + "has_label_column": false, + "kind": "labels_csv", + "requires_filename_column": true + }, + "primary_subdir": "sequences", + "record_format": null, + "sidecars": [] + }, + "object_detection": { + "family": "image", + "manifest": { + "has_label_column": true, + "kind": "labels_csv", + "requires_filename_column": true + }, + "primary_subdir": "images", + "record_format": null, + "sidecars": [ + { + "glob": "*.xml", + "link_column": null, + "required": true, + "subdir": "annotations" + } + ] + }, + "semantic_segmentation": { + "family": "image", + "manifest": { + "has_label_column": true, + "kind": "labels_csv", + "requires_filename_column": true + }, + "primary_subdir": "images", + "record_format": null, + "sidecars": [ + { + "glob": "*.png", + "link_column": "mask_id", + "required": true, + "subdir": "masks" + } + ] + }, + "sentence_pair_classification": { + "family": "text", + "manifest": { + "has_label_column": true, + "kind": "labels_csv", + "requires_filename_column": true + }, + "primary_subdir": "texts", + "record_format": { + "enforced": true, + "fields": [ + "text_a", + "text_b" + ], + "min_fields": 2, + "separator": "\t" + }, + "sidecars": [] + }, + "seq2seq": { + "family": "text", + "manifest": { + "has_label_column": false, + "kind": "labels_csv", + "requires_filename_column": true + }, + "primary_subdir": "texts", + "record_format": { + "enforced": false, + "fields": [ + "source", + "target" + ], + "min_fields": 1, + "separator": "\t" + }, + "sidecars": [] + }, + "tabular_classification": { + "family": "tabular", + "manifest": { + "has_label_column": true, + "kind": "data_csv", + "requires_filename_column": false + }, + "primary_subdir": null, + "record_format": null, + "sidecars": [] + }, + "tabular_regression": { + "family": "tabular", + "manifest": { + "has_label_column": true, + "kind": "data_csv", + "requires_filename_column": false + }, + "primary_subdir": null, + "record_format": null, + "sidecars": [] + }, + "text_classification": { + "family": "text", + "manifest": { + "has_label_column": true, + "kind": "labels_csv", + "requires_filename_column": true + }, + "primary_subdir": "texts", + "record_format": null, + "sidecars": [] + }, + "time_series_forecasting": { + "family": "tabular", + "manifest": { + "has_label_column": true, + "kind": "data_csv", + "requires_filename_column": false + }, + "primary_subdir": null, + "record_format": null, + "sidecars": [] + }, + "time_to_event_prediction": { + "family": "tabular", + "manifest": { + "has_label_column": true, + "kind": "data_csv", + "requires_filename_column": false + }, + "primary_subdir": null, + "record_format": null, + "sidecars": [] + }, + "token_classification": { + "family": "text", + "manifest": { + "has_label_column": true, + "kind": "labels_csv", + "requires_filename_column": true + }, + "primary_subdir": "texts", + "record_format": null, + "sidecars": [] + } + }, + "version": "1" +} diff --git a/scripts/sync-schema.sh b/scripts/sync-schema.sh index d4b42a4c..1fa12e5c 100755 --- a/scripts/sync-schema.sh +++ b/scripts/sync-schema.sh @@ -1,11 +1,17 @@ #!/usr/bin/env bash -# Sync ingest.v1.json from tracebloc/data-ingestors into the CLI's -# embedded copy at internal/schema/ingest.v1.json. +# Sync the CLI's embedded contract files from tracebloc/data-ingestors: # -# The CLI validates locally using this schema. Drift between the +# - ingest.v1.json — the ingest-config JSON Schema the CLI validates against +# - layout.v1.json — the per-task dataset-layout contract (data-ingestors +# #347/#353) the CLI mirrors for discovery + staging +# +# both under internal/schema/. +# +# The CLI validates locally using these files. Drift between the # CLI's copy and data-ingestors' canonical source is a real # correctness hazard — a customer's YAML could pass `tracebloc ingest -# validate` locally but be rejected by jobs-manager (or vice versa). +# validate` locally but be rejected by jobs-manager (or vice versa), or the +# CLI could stage a layout the ingestor rejects. # # Run this script when bumping the schema version. CI invokes it in # check-mode (`--check`) to fail builds that have drifted without @@ -16,11 +22,12 @@ # scripts/sync-schema.sh --check # verify in-tree copy matches upstream; exit non-zero on drift # # Env knobs: -# SCHEMA_SOURCE_URL override the upstream URL (default: built from the -# pinned ref below) +# SCHEMA_SOURCE_URL override the upstream URL for ingest.v1.json (default: +# built from the pinned ref below) # DATA_INGESTORS_REF override the data-ingestors ref (default: the pinned # SHA in scripts/.data-ingestors-ref, else master) -# SCHEMA_OUT override the in-tree destination (default: internal/schema/ingest.v1.json) +# SCHEMA_OUT override the in-tree destination for ingest.v1.json +# (default: internal/schema/ingest.v1.json) # # The ref is PINNED (scripts/.data-ingestors-ref), not a floating branch, so an # unrelated upstream commit doesn't red every open CLI PR — adopting upstream @@ -54,61 +61,118 @@ if ! printf '%s' "$DATA_INGESTORS_REF" | grep -qE '^[A-Za-z0-9][A-Za-z0-9._/-]*$ exit 2 fi -readonly DEFAULT_URL="https://raw.githubusercontent.com/tracebloc/data-ingestors/${DATA_INGESTORS_REF}/tracebloc_ingestor/schema/ingest.v1.json" +readonly UPSTREAM_BASE="https://raw.githubusercontent.com/tracebloc/data-ingestors/${DATA_INGESTORS_REF}/tracebloc_ingestor/schema" + +readonly DEFAULT_URL="${UPSTREAM_BASE}/ingest.v1.json" readonly DEFAULT_OUT="internal/schema/ingest.v1.json" +# ingest.v1.json keeps its historical env overrides; layout.v1.json is derived +# from the pinned ref. Each entry is "URL|OUT". SCHEMA_SOURCE_URL="${SCHEMA_SOURCE_URL:-$DEFAULT_URL}" SCHEMA_OUT="${SCHEMA_OUT:-$DEFAULT_OUT}" +FILES=( + "${SCHEMA_SOURCE_URL}|${SCHEMA_OUT}" + "${UPSTREAM_BASE}/layout.v1.json|internal/schema/layout.v1.json" +) CHECK_MODE=false if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=true fi -# Stage the fetched schema in a temp file so a half-failed curl doesn't -# leave a truncated file in the repo. -tmp=$(mktemp) -trap 'rm -f "$tmp"' EXIT +# Track every temp file we stage so a single top-level trap can clean them all +# up — on normal exit AND on a signal-driven one (SIGINT/SIGTERM during the curl +# fetch or json.tool validation). The pre-refactor code used a top-level EXIT +# trap; a per-function RETURN trap alone would leak the temp file when the run +# is killed mid-fetch, so keep cleanup at the top level. +_tmpfiles=() +cleanup_tmpfiles() { + # Guard the expansion: under `set -u`, "${arr[@]}" on an empty array is an + # unbound-variable error in bash < 4.4 (macOS ships 3.2). + [[ ${#_tmpfiles[@]} -eq 0 ]] && return 0 + local f + for f in "${_tmpfiles[@]}"; do + rm -f "$f" + done +} +trap cleanup_tmpfiles EXIT INT TERM -echo "==> fetching $SCHEMA_SOURCE_URL" -curl -fsSL "$SCHEMA_SOURCE_URL" -o "$tmp" +# sync_one fetches one upstream file and either checks it against the in-tree +# copy (--check) or writes it. Returns non-zero on drift / missing file in +# check mode. Each fetch is staged in its own temp file so a half-failed curl +# never leaves a truncated file in the repo. +sync_one() { + local url="$1" out="$2" + local tmp + tmp=$(mktemp) + _tmpfiles+=("$tmp") -# Make sure what came back is valid JSON before we trust it. -if ! python3 -m json.tool < "$tmp" > /dev/null 2>&1; then - echo "error: upstream response is not valid JSON" >&2 - echo "first 200 bytes of response:" >&2 - head -c 200 "$tmp" >&2 - exit 2 -fi + echo "==> fetching $url" + # sync_one is called as `if ! sync_one ...`, which suspends `set -e` for the + # whole body — so a failed curl (e.g. a 404) would otherwise fall through and + # be misdiagnosed as "not valid JSON" on the empty temp file. Check curl's + # exit explicitly and report the real fetch failure. --tlsv1.2 matches every + # other curl in the repo (scripts/install.sh). + curl -fsSL --tlsv1.2 "$url" -o "$tmp" + local curl_rc=$? + if [[ $curl_rc -ne 0 ]]; then + echo "error: failed to fetch $url (curl exited $curl_rc)" >&2 + return "$curl_rc" + fi -mkdir -p "$(dirname "$SCHEMA_OUT")" + # Make sure what came back is valid JSON before we trust it. + if ! python3 -m json.tool < "$tmp" > /dev/null 2>&1; then + echo "error: upstream response is not valid JSON ($url)" >&2 + echo "first 200 bytes of response:" >&2 + head -c 200 "$tmp" >&2 + return 2 + fi -if $CHECK_MODE; then - if [[ ! -f "$SCHEMA_OUT" ]]; then - echo "error: $SCHEMA_OUT does not exist" >&2 - echo "run \`scripts/sync-schema.sh\` (without --check) to seed it." >&2 - exit 1 + mkdir -p "$(dirname "$out")" + + if $CHECK_MODE; then + if [[ ! -f "$out" ]]; then + echo "error: $out does not exist" >&2 + echo "run \`scripts/sync-schema.sh\` (without --check) to seed it." >&2 + return 1 + fi + if ! diff -q "$tmp" "$out" >/dev/null; then + echo "error: $out has drifted from upstream." >&2 + echo "diff (upstream → in-tree):" >&2 + diff -u "$out" "$tmp" | head -40 >&2 || true + echo >&2 + echo "to fix, bump scripts/.data-ingestors-ref if needed, run \`scripts/sync-schema.sh\`, and commit the result." >&2 + return 1 + fi + echo "==> $out matches upstream — no drift" + return 0 fi - if ! diff -q "$tmp" "$SCHEMA_OUT" >/dev/null; then - echo "error: $SCHEMA_OUT has drifted from upstream." >&2 - echo "diff (upstream → in-tree):" >&2 - diff -u "$SCHEMA_OUT" "$tmp" | head -40 >&2 || true - echo >&2 - echo "to fix, run \`scripts/sync-schema.sh\` and commit the result." >&2 - exit 1 + + # Write mode. Only touch the destination if the content actually changed, so + # re-running the script on an already-current file produces no mtime churn. + if [[ -f "$out" ]] && diff -q "$tmp" "$out" >/dev/null; then + echo "==> $out already matches upstream — no change" + return 0 fi - echo "==> $SCHEMA_OUT matches upstream — no drift" - exit 0 -fi -# Write mode. Only touch the destination if the content actually -# changed, so re-running the script on an already-current schema -# produces no file-mtime churn. -if [[ -f "$SCHEMA_OUT" ]] && diff -q "$tmp" "$SCHEMA_OUT" >/dev/null; then - echo "==> $SCHEMA_OUT already matches upstream — no change" - exit 0 -fi + # Check the write explicitly: sync_one is called as `if ! sync_one ...`, which + # suspends `set -e` for the whole function body, so a failed cp (unwritable + # dir, full disk) would otherwise fall through to the "wrote" line and return + # 0 — a false success that lets a stale vendored file get committed. + if ! cp "$tmp" "$out"; then + echo "error: failed to write $out (check directory permissions / disk space)" >&2 + return 1 + fi + echo "==> wrote $out ($(wc -c < "$out" | tr -d ' ') bytes)" + return 0 +} -mv "$tmp" "$SCHEMA_OUT" -trap - EXIT # the temp file is now in place; don't try to rm it -echo "==> wrote $SCHEMA_OUT ($(wc -c < "$SCHEMA_OUT" | tr -d ' ') bytes)" +rc=0 +for entry in "${FILES[@]}"; do + url="${entry%%|*}" + out="${entry##*|}" + if ! sync_one "$url" "$out"; then + rc=1 + fi +done +exit "$rc" From 88f2bf3a46f86c0b203e458ca10fc02e4937d5ec Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:36:28 +0200 Subject: [PATCH 09/22] feat(data ingest): confirm the inferred tabular schema (#185) (#210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(data ingest): confirm the inferred tabular schema (#185) Mirror data-ingestors' di#349 tabular type-inference in the Go CLI (InferSchema → *SchemaInference), show the inferred col:TYPE for the 4 tabular/time tasks, let the user confirm/amend, and EMIT the confirmed schema explicitly as spec.schema — so the ingestor uses the CLI's (di#349-correct) types regardless of its deployed version. Leading-zero codes stay VARCHAR (the #349 fix), id-like INT and empty-in-sample columns are surfaced in the confirm step. Pinned to di#349's value->type contract via a venv-free Go parity test (testdata/schema_inference_parity.json, 21 cases). Inference is a pure-Go mirror — no schema-ref bump needed (the min_size/efaeb07 drag-in belongs to #183), so no validator-goldens regen. Co-Authored-By: Claude Opus 4.8 * fix(data ingest): address #185 review findings - Mixed-timezone DATETIME parity fix: a tabular column of RFC3339 values with non-uniform UTC offsets (or a mix of tz-aware and tz-naive) is now routed to VARCHAR, matching the ingestor's schema_inference._infer_datetime (pd.to_datetime(format="mixed") returns None on mixed timezones, di#349). Previously each offset token parsed individually via time.RFC3339 and the column was typed DATETIME, so the emitted spec.schema was a tz-naive DATETIME that silently dropped the per-row offset in-cluster. Pinned with TestInferColumnType_TimezoneParity (verified against di#349 / pandas 3.0.3); the shared ASCII-only parity fixture is left untouched — di#349 carries no tz case, so adding one would diverge the vendored contract. - Remove dead API surface built for an interactive confirm/amend step that is not part of this PR: SerializeSchema, the InferredColumn type, and SchemaInference.Columns had zero callers/readers repo-wide. - Correct comments that described that unbuilt step as if it existed (data.go schema branch; the InferSchema / SchemaInference docs): the CLI infers the schema mirroring di#349 and EMITS it explicitly; the interactive prompt captures an optional --schema override; risky columns are surfaced as warnings. - Refresh the stale --schema flag help to the full inferred type set (INT/BIGINT/FLOAT/BOOLEAN/DATE/DATETIME/VARCHAR(n)). Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- internal/cli/data.go | 35 +- internal/push/parity_golden_test.go | 8 +- internal/push/schema_inference_parity_test.go | 43 ++ internal/push/tabular.go | 448 +++++++++++++++--- internal/push/tabular_test.go | 82 +++- .../testdata/schema_inference_parity.json | 27 ++ 6 files changed, 533 insertions(+), 110 deletions(-) create mode 100644 internal/push/schema_inference_parity_test.go create mode 100644 internal/push/testdata/schema_inference_parity.json diff --git a/internal/cli/data.go b/internal/cli/data.go index 812839df..a96bee51 100644 --- a/internal/cli/data.go +++ b/internal/cli/data.go @@ -315,7 +315,7 @@ Exit codes: "size check).") cmd.Flags().StringVar(&schemaFlag, "schema", "", "tabular/time-series only: column types as col:TYPE,col:TYPE (e.g. age:INT,price:FLOAT). "+ - "Default: inferred from the CSV (INT/FLOAT/VARCHAR).") + "Default: inferred from the CSV (INT/BIGINT/FLOAT/BOOLEAN/DATE/DATETIME/VARCHAR(n)).") cmd.Flags().StringVar(&labelPolicy, "label-policy", "", "regression-class only (tabular_regression, time_series_forecasting, time_to_event_prediction): "+ "passthrough|bucket (default bucket — bins the target so the raw value never leaves the cluster)") @@ -638,9 +638,14 @@ collaborators can train against that table without ever seeing the raw files.`)) 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. + // Column schema. An explicit --schema wins (raw flag, or the + // optional override the interactive prompt captures into SchemaFlag). + // Otherwise infer the types here — mirroring the ingestor's own rules + // (di#349) — and EMIT the result explicitly (below, via a.Spec.Schema + // → spec.schema), so the ingestor uses the CLI's answer regardless of + // its own version. Inference runs on both a no-schema non-interactive + // run and an interactive run where the user left the schema prompt + // blank; the risky cases below are surfaced as warnings. if a.SchemaFlag != "" { sch, perr := push.ParseSchema(a.SchemaFlag) if perr != nil { @@ -648,22 +653,28 @@ collaborators can train against that table without ever seeing the raw files.`)) } a.Spec.Schema = sch } else { - sch, skipped, empty, ierr := push.InferSchema(layout.LabelsCSV) + res, ierr := push.InferSchema(layout.LabelsCSV) if ierr != nil { return &exitError{code: 3, err: fmt.Errorf("inferring schema from CSV: %w", ierr)} } - a.Spec.Schema = sch + a.Spec.Schema = res.Schema _, _ = fmt.Fprintf(out, "Inferred schema for %d column(s) from %s (override with --schema).\n", - len(sch), filepath.Base(layout.LabelsCSV)) - if len(skipped) > 0 { + len(res.Schema), filepath.Base(layout.LabelsCSV)) + if len(res.Skipped) > 0 { + _, _ = fmt.Fprintf(out, + " (skipped framework-managed column(s): %s)\n", strings.Join(res.Skipped, ", ")) + } + if len(res.Empty) > 0 { _, _ = fmt.Fprintf(out, - " (skipped framework-managed column(s): %s)\n", strings.Join(skipped, ", ")) + " (warning: %d column(s) had no values in the sample and were typed VARCHAR(1): %s)\n", + len(res.Empty), strings.Join(res.Empty, ", ")) } - if len(empty) > 0 { + if len(res.IDLike) > 0 { _, _ = fmt.Fprintf(out, - " (warning: %d column(s) had no values in the sample and were typed FLOAT (nullable): %s)\n", - len(empty), strings.Join(empty, ", ")) + " (warning: %d column(s) look like identifiers (all-unique integers): %s — "+ + "if any is a zero-padded code, pass --schema to type it VARCHAR)\n", + len(res.IDLike), strings.Join(res.IDLike, ", ")) } } case push.IsImage(a.Spec.Category): diff --git a/internal/push/parity_golden_test.go b/internal/push/parity_golden_test.go index 9fe6efff..2f15dbd4 100644 --- a/internal/push/parity_golden_test.go +++ b/internal/push/parity_golden_test.go @@ -115,8 +115,8 @@ func goLabelValues(t *testing.T, c parityCase) LabelReadValues { csvPath := filepath.Join("testdata", "parity", "cases", c.Name, c.CSV) schema := c.Schema if IsTabular(c.Category) && len(schema) == 0 { - if sch, _, _, err := InferSchema(csvPath); err == nil { - schema = sch + if res, err := InferSchema(csvPath); err == nil { + schema = res.Schema } } dropNA, collapse := false, false @@ -152,8 +152,8 @@ func runGoPreflight(t *testing.T, c parityCase) string { // schema the same way, so dtype-sensitive verdicts stay comparable. if len(c.Schema) > 0 { spec.Schema = c.Schema - } else if sch, _, _, err := InferSchema(layout.LabelsCSV); err == nil { - spec.Schema = sch + } else if res, err := InferSchema(layout.LabelsCSV); err == nil { + spec.Schema = res.Schema } } _, problem := PreflightDataset(spec, layout) diff --git a/internal/push/schema_inference_parity_test.go b/internal/push/schema_inference_parity_test.go new file mode 100644 index 00000000..738fe1de --- /dev/null +++ b/internal/push/schema_inference_parity_test.go @@ -0,0 +1,43 @@ +package push + +import ( + "encoding/json" + "os" + "testing" +) + +// TestSchemaInferenceParity pins the Go CLI's tabular type inference against +// data-ingestors' committed value->type contract +// (testdata/schema_inference_parity.json, vendored from di#349's +// tests/fixtures/schema_inference_parity.json). The ingestor's +// schema_inference.infer_column_type is the source of truth (Principle 6 / +// backend#1009); this test fails if the Go mirror drifts from it — a +// static, venv-free parity check the CI can run on every PR. +func TestSchemaInferenceParity(t *testing.T) { + data, err := os.ReadFile("testdata/schema_inference_parity.json") + if err != nil { + t.Fatalf("read parity fixture: %v", err) + } + var fixture struct { + Cases []struct { + Name string `json:"name"` + Values []string `json:"values"` + Expected string `json:"expected"` + } `json:"cases"` + } + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatalf("parse parity fixture: %v", err) + } + if len(fixture.Cases) == 0 { + t.Fatal("parity fixture has no cases — the di#349 contract is empty") + } + for _, c := range fixture.Cases { + t.Run(c.Name, func(t *testing.T) { + // inferColumnType cleans (trim + drop empty/NA) then classifies, + // mirroring the ingestor's per-column path exactly. + if got := inferColumnType(c.Values); got != c.Expected { + t.Errorf("inferColumnType(%v) = %q, want %q (di#349 contract)", c.Values, got, c.Expected) + } + }) + } +} diff --git a/internal/push/tabular.go b/internal/push/tabular.go index ac0256b2..b60679f6 100644 --- a/internal/push/tabular.go +++ b/internal/push/tabular.go @@ -4,11 +4,15 @@ import ( "encoding/csv" "fmt" "io" + "math" "os" "path/filepath" + "regexp" "sort" "strconv" "strings" + "time" + "unicode/utf8" ) // reservedColumns are framework-managed columns the ingestor adds @@ -32,13 +36,74 @@ var reservedColumns = map[string]bool{ } // schemaInferenceSampleRows caps how many data rows InferSchema reads -// to decide each column's type. The whole CSV would be more accurate -// but a few thousand rows is plenty to distinguish INT/FLOAT/text in -// practice, and bounds the work for large files. A column whose true -// type only reveals itself past the sample (e.g. an int column that -// turns float on row 10k) is the case --schema exists to override. +// to decide each column's type. It MIRRORS data-ingestors' +// schema_inference.SAMPLE_CAP (5000) so the two implementations agree on +// the same prefix of a file (di#349). The whole CSV would be more +// accurate but a few thousand rows is plenty to distinguish the SQL +// types in practice, and bounds the work for large files. A column whose +// true type only reveals itself past the sample (e.g. an int column that +// turns float on row 10k, or a zero-padded code that first appears past +// the cap) is the case --schema exists to override. +// The value-level parity fixture pins this equality +// (internal/push/testdata/schema_inference_parity.json, "sample_cap"). const schemaInferenceSampleRows = 5000 +// Signed 32-bit bounds. A parsed integer outside this range needs BIGINT, +// not INT, or it overflows a MySQL INT column on write. Mirrors +// schema_inference.INT32_MIN/INT32_MAX (di#349). The int64 bound is +// enforced by strconv.ParseInt(…, 10, 64) erroring on overflow — an +// all-digit value beyond int64 is not storable as an integer and falls +// through to VARCHAR, matching the ingestor. +const ( + int32Min = -2147483648 + int32Max = 2147483647 +) + +// boolText is the textual-boolean vocabulary — deliberately NOT the 0/1 +// digit forms. A pure 0/1 column is inferred INT (lossless); only these +// unambiguous words map to BOOLEAN. Mirrors schema_inference._BOOL_TEXT +// (di#349), a deliberate subset of coercion.BOOL_STRINGS. +var boolText = map[string]struct{}{ + "true": {}, "false": {}, "t": {}, "f": {}, + "yes": {}, "no": {}, "y": {}, "n": {}, +} + +// ASCII-only digit grammars ([0-9], not \d). Restricting to ASCII routes +// Unicode-digit and underscore-grouped tokens to VARCHAR, keeping the CLI +// and the ingestor in lockstep (schema_inference._LEADING_ZERO_CODE / +// _INT_RE / _FLOAT_RE). The float grammar pre-screens the token before +// ParseFloat so Go and Python reject the same non-finite / grouped forms. +var ( + leadingZeroCodeRE = regexp.MustCompile(`^[+-]?0[0-9]+$`) + intRE = regexp.MustCompile(`^[+-]?[0-9]+$`) + floatRE = regexp.MustCompile(`^[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$`) +) + +// dateLayouts / datetimeLayouts are the naive (no-timezone) calendar forms +// the CLI recognizes when mirroring schema_inference's date rule (rule 5, +// checked AFTER numeric so an all-digit id like "20240101" stays INT). +// data-ingestors uses pandas' liberal to_datetime; the CLI cannot reproduce +// every pandas spelling in Go, so it recognizes the common ISO forms and +// otherwise falls back to VARCHAR — the SAFE direction: a mis-typed VARCHAR +// is correctable with --schema, and because the CLI EMITS the schema +// explicitly, the CLI's answer is authoritative in-cluster regardless of the +// deployed ingestor's own guess. The tz-aware RFC3339 form is parsed +// separately (parseCalendarDate) so a column of MIXED UTC offsets is detected +// and routed to VARCHAR — matching the ingestor, whose _infer_datetime +// returns None (→ VARCHAR) on mixed timezones. The value-level parity fixture +// pins the forms that must agree. +var ( + dateLayouts = []string{"2006-01-02", "2006/01/02"} + // Naive datetime layouts only — tz-aware RFC3339 is handled explicitly + // in parseCalendarDate (see the mixed-offset guard there). + datetimeLayouts = []string{ + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + "2006-01-02 15:04", + "2006/01/02 15:04:05", + } +) + // DiscoverTabular validates a local input for a tabular / time-series // ingestion. Unlike the image layout, tabular categories have NO // sidecar files — the dataset IS a single CSV. Two shapes are accepted @@ -184,100 +249,343 @@ func ParseSchema(s string) (map[string]string, error) { return out, nil } -// InferSchema reads the CSV header and a sample of rows and infers a -// column→SQL-type map: all-integer columns → INT, otherwise -// all-numeric → FLOAT, otherwise VARCHAR(255). Empty cells are -// ignored when judging a column; a column with NO non-empty sampled -// value is typed as a nullable FLOAT (not VARCHAR — an all-NULL VARCHAR -// is exactly what the ingestor's string validator rejects) and returned -// in `empty` so the caller can warn. -// -// It's a convenience so customers don't hand-write a --schema for the -// common case. Non-numeric specials (timestamps, dates, booleans) -// infer as VARCHAR(255); pass --schema to declare them precisely. +// SchemaInference is the result of inferring a tabular schema from a CSV. +// Schema is the column→SQL-type map the CLI emits as spec.schema. +// Skipped/Empty/IDLike are the risky cases surfaced to the user as warnings +// so a wrong guess can be corrected with --schema before anything is +// ingested — never a silent guess (RFC-0002 §8.1). +type SchemaInference struct { + // Schema maps each non-reserved column to its inferred SQL type. + Schema map[string]string + // Skipped lists framework-managed columns (see reservedColumns) that + // were excluded — the ingestor adds them itself and rejects a schema + // that redeclares them. + Skipped []string + // Empty lists columns with NO non-missing value in the sample; they + // are typed VARCHAR(1) (mirroring the ingestor's all-missing rule) and + // flagged because the type is a guess with no evidence behind it. + Empty []string + // IDLike lists INT/BIGINT columns whose sampled values are all + // distinct — they look like identifiers. If such a column is really a + // zero-padded code the leading zeros were stripped somewhere upstream, + // or it should be a VARCHAR; the warning points it out. + IDLike []string +} + +// InferSchema reads the CSV header and up to schemaInferenceSampleRows +// data rows and infers a column→SQL-type map, MIRRORING data-ingestors' +// schema_inference.infer_schema (di#349) column-for-column via +// inferColumnType. The ingestor OWNS these rules; the CLI mirrors them so +// the schema it EMITS is the same answer the ingestor would compute — and, +// because it is emitted explicitly (a.Spec.Schema → spec.schema), is +// authoritative in-cluster regardless of the deployed ingestor version +// (RFC-0002 Principle 6). The value-level parity fixture pins the two +// implementations to the same per-column answer. // -// Framework-managed columns (see reservedColumns — id, data_id, …) -// are skipped and returned as the second value so the caller can tell -// the customer they weren't included. -func InferSchema(csvPath string) (schema map[string]string, skipped, empty []string, err error) { +// Framework-managed columns (see reservedColumns — id, data_id, …) are +// skipped: the ingestor adds them itself and rejects a schema that +// redeclares them. The risky cases (empty-in-sample, id-like) are returned +// alongside the schema so the caller can surface them as warnings. +func InferSchema(csvPath string) (*SchemaInference, error) { f, err := os.Open(csvPath) if err != nil { - return nil, nil, nil, err + return nil, err } defer func() { _ = f.Close() }() r := csv.NewReader(f) + r.FieldsPerRecord = -1 // ragged rows are CheckDuplicateHeaders' / read-time's diagnostic, not ours header, err := r.Read() if err != nil { - return nil, nil, nil, fmt.Errorf("reading CSV header from %s: %w", csvPath, err) + return nil, fmt.Errorf("reading CSV header from %s: %w", csvPath, err) } if len(header) == 0 { - return nil, nil, nil, fmt.Errorf("CSV %s has no columns", csvPath) - } - - // Per-column running judgement. - couldBeInt := make([]bool, len(header)) - couldBeFloat := make([]bool, len(header)) - sawValue := make([]bool, len(header)) - for i := range header { - couldBeInt[i] = true - couldBeFloat[i] = true + return nil, fmt.Errorf("CSV %s has no columns", csvPath) } + // Collect each column's raw sampled values (row-capped at + // schemaInferenceSampleRows to match the ingestor's SAMPLE_CAP), then + // infer per column. Cleaning (trim + drop empty/NA) happens inside + // cleanTokens so inferColumnType matches the ingestor byte-for-byte. + cols := make([][]string, len(header)) for n := 0; n < schemaInferenceSampleRows; n++ { row, err := r.Read() if err == io.EOF { break } if err != nil { - return nil, nil, nil, fmt.Errorf("reading CSV row from %s: %w", csvPath, err) + return nil, fmt.Errorf("reading CSV row from %s: %w", csvPath, err) } for i := 0; i < len(header) && i < len(row); i++ { - v := strings.TrimSpace(row[i]) - if v == "" { - continue - } - sawValue[i] = true - if couldBeInt[i] { - if _, e := strconv.ParseInt(v, 10, 64); e != nil { - couldBeInt[i] = false - } - } - if couldBeFloat[i] { - if _, e := strconv.ParseFloat(v, 64); e != nil { - couldBeFloat[i] = false - } - } + cols[i] = append(cols[i], row[i]) } } - schema = make(map[string]string, len(header)) + res := &SchemaInference{Schema: make(map[string]string, len(header))} for i, col := range header { col = strings.TrimSpace(col) if reservedColumns[col] { - // Framework-managed (id, data_id, …): the ingestor adds it - // and rejects a schema that redeclares it. Skip + report. - skipped = append(skipped, col) + res.Skipped = append(res.Skipped, col) + continue + } + tokens := cleanTokens(cols[i]) + typ := classifyTokens(tokens) + res.Schema[col] = typ + if len(tokens) == 0 { + res.Empty = append(res.Empty, col) + } else if isIntegerType(typ) && allDistinct(tokens) { + res.IDLike = append(res.IDLike, col) + } + } + return res, nil +} + +// inferColumnType returns the SQL type for one column from its RAW values, +// a faithful mirror of schema_inference.infer_column_type (di#349). Used +// directly by the value-level parity test. See classifyTokens for the +// rule precedence. +func inferColumnType(values []string) string { + return classifyTokens(cleanTokens(values)) +} + +// cleanTokens takes the first schemaInferenceSampleRows raw values, trims +// each, and drops empty / NA-sentinel tokens — mirroring +// schema_inference._clean_tokens (the cap is applied to the RAW values, +// then missing tokens are dropped). naSentinels (preflight.go) is the same +// set as the ingestor's coercion.NA_SENTINELS. +func cleanTokens(values []string) []string { + if len(values) > schemaInferenceSampleRows { + values = values[:schemaInferenceSampleRows] + } + out := make([]string, 0, len(values)) + for _, v := range values { + s := strings.TrimSpace(v) + if s == "" { + continue + } + if _, isNA := naSentinels[s]; isNA { continue } - switch { - case sawValue[i] && couldBeInt[i]: - schema[col] = "INT" - case sawValue[i] && couldBeFloat[i]: - schema[col] = "FLOAT" - case !sawValue[i]: - // Entirely empty in the sample (e.g. an unmeasured analyte in a - // sparse panel). It can't be typed from data; default to a - // nullable FLOAT rather than VARCHAR — a tabular feature column - // is numeric far more often than text, and an all-NULL VARCHAR - // is exactly the shape the ingestor's string validator rejects. - // Reported in `empty` so the caller can warn / the user can - // --schema-override. - schema[col] = "FLOAT" - empty = append(empty, col) - default: - schema[col] = "VARCHAR(255)" + out = append(out, s) + } + return out +} + +// classifyTokens applies the di#349 rule precedence (FIRST MATCH WINS) to +// already-cleaned tokens: +// +// 0. no tokens (all missing) -> VARCHAR(1) +// 1. any leading-zero code -> VARCHAR(n) (THE #349 fix: "007" is text) +// 2. all textual boolean -> BOOLEAN (0/1 is INT, not BOOL) +// 3. all integer -> INT / BIGINT (BIGINT past int32; >int64 -> text) +// 4. all finite float -> FLOAT +// 5. all date / datetime -> DATE / DATETIME (after numeric) +// 6. otherwise -> VARCHAR(n) +// +// VARCHAR(n) sizes n by RUNE count (utf8.RuneCountInString), matching +// MySQL VARCHAR(n) character semantics and the ingestor's char-count +// sizing — NOT byte length. +func classifyTokens(tokens []string) string { + if len(tokens) == 0 { + return "VARCHAR(1)" + } + + // 1. Leading-zero code — one such token pins the column to text. + for _, t := range tokens { + if leadingZeroCodeRE.MatchString(t) { + return varcharOf(tokens) + } + } + + // 2. Textual boolean. + if allBoolText(tokens) { + return "BOOLEAN" + } + + // 3. Integer (INT vs BIGINT by magnitude; >int64 all-digit -> text). + if allMatch(intRE, tokens) { + return integerType(tokens) + } + + // 4. Float. + if allFiniteFloat(tokens) { + return "FLOAT" + } + + // 5. Date / datetime (after numeric, so numeric ids can't be mis-dated). + if dt := inferDatetime(tokens); dt != "" { + return dt + } + + // 6. Fallback. + return varcharOf(tokens) +} + +func allBoolText(tokens []string) bool { + for _, t := range tokens { + if _, ok := boolText[strings.ToLower(t)]; !ok { + return false + } + } + return true +} + +func allMatch(re *regexp.Regexp, tokens []string) bool { + for _, t := range tokens { + if !re.MatchString(t) { + return false + } + } + return true +} + +// integerType assumes every token already matched intRE. It returns INT +// (all within signed int32), BIGINT (within int64 but past int32), or +// VARCHAR (any token beyond int64 — not storable as an integer). Mirrors +// schema_inference rule 3. +func integerType(tokens []string) string { + widen := false + for _, t := range tokens { + v, err := strconv.ParseInt(t, 10, 64) + if err != nil { + // Beyond int64: not storable as an integer -> text. + return varcharOf(tokens) + } + if v < int32Min || v > int32Max { + widen = true + } + } + if widen { + return "BIGINT" + } + return "INT" +} + +// allFiniteFloat reports whether every token is a finite float under the +// ASCII float grammar — the regex pre-screen rejects underscore grouping, +// Unicode digits, and the inf/nan spellings ParseFloat would otherwise +// accept, and the isfinite guard catches a regex-valid overflow ("1e400"). +func allFiniteFloat(tokens []string) bool { + for _, t := range tokens { + if !floatRE.MatchString(t) { + return false + } + fv, err := strconv.ParseFloat(t, 64) + if err != nil || math.IsInf(fv, 0) || math.IsNaN(fv) { + return false + } + } + return true +} + +// inferDatetime returns "DATE"/"DATETIME" if every token parses as a +// calendar date under the recognized layouts, else "". Guard: each token +// must contain an ASCII digit (so plain words / month names stay text) — +// mirrors schema_inference._infer_datetime's ASCII-digit guard. hasTime is +// true when any token carries a time-of-day component. +// +// Mixed-offset guard: a column whose tokens don't all share one timezone key +// (tz-aware values with differing UTC offsets, or a mix of tz-aware and +// tz-naive values) is not a single-timezone calendar column, so it falls back +// to VARCHAR. This mirrors the ingestor, where pd.to_datetime(format="mixed") +// returns None on mixed timezones (schema_inference._infer_datetime) — without +// the guard the CLI would emit a tz-naive DATETIME that silently drops the +// per-row offset. +func inferDatetime(tokens []string) string { + hasTime := false + tzKey := "" + haveTZ := false + for _, t := range tokens { + if !containsASCIIDigit(t) { + return "" + } + ok, withTime, key := parseCalendarDate(t) + if !ok { + return "" + } + if withTime { + hasTime = true + } + if !haveTZ { + tzKey, haveTZ = key, true + } else if key != tzKey { + // Non-uniform timezone across the column — VARCHAR, matching the + // ingestor's None result for mixed timezones. + return "" + } + } + if hasTime { + return "DATETIME" + } + return "DATE" +} + +func containsASCIIDigit(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] >= '0' && s[i] <= '9' { + return true + } + } + return false +} + +// parseCalendarDate reports whether t parses under a recognized date / +// datetime layout, whether the matched layout carries a time, and a timezone +// key used to detect a column of mixed UTC offsets. A tz-aware RFC3339 token +// keys on its offset in seconds ("z"); every naive (no-timezone) date +// or datetime layout keys as "naive". A column whose tokens don't all share +// one key is not a single-timezone calendar column (see inferDatetime's +// mixed-offset guard). +func parseCalendarDate(t string) (ok, withTime bool, tzKey string) { + // tz-aware: RFC3339 carries an explicit offset (or Z). + if tm, err := time.Parse(time.RFC3339, t); err == nil { + _, off := tm.Zone() + return true, true, "z" + strconv.Itoa(off) + } + for _, layout := range datetimeLayouts { + if _, err := time.Parse(layout, t); err == nil { + return true, true, "naive" + } + } + for _, layout := range dateLayouts { + if _, err := time.Parse(layout, t); err == nil { + return true, false, "naive" + } + } + return false, false, "" +} + +// varcharOf sizes VARCHAR(n) by the longest sampled value in RUNES (code +// points), floor 1 — MySQL VARCHAR(n) counts characters, so a multibyte +// value must not be sized by its UTF-8 byte length. Mirrors +// schema_inference._varchar. +func varcharOf(tokens []string) string { + n := 1 + for _, t := range tokens { + if c := utf8.RuneCountInString(t); c > n { + n = c + } + } + return fmt.Sprintf("VARCHAR(%d)", n) +} + +// isIntegerType reports whether an inferred type is INT or BIGINT — used +// to flag id-like columns (all-distinct integers). +func isIntegerType(typ string) bool { + return typ == "INT" || typ == "BIGINT" +} + +// allDistinct reports whether every token is unique. An all-distinct +// integer column looks like an identifier — flagged so the user can +// confirm it is a real feature (and not a code whose leading zeros were +// lost upstream). +func allDistinct(tokens []string) bool { + seen := make(map[string]struct{}, len(tokens)) + for _, t := range tokens { + if _, dup := seen[t]; dup { + return false } + seen[t] = struct{}{} } - return schema, skipped, empty, nil + return true } diff --git a/internal/push/tabular_test.go b/internal/push/tabular_test.go index d47d920e..f9e3440b 100644 --- a/internal/push/tabular_test.go +++ b/internal/push/tabular_test.go @@ -142,48 +142,50 @@ func TestDiscoverTabular_MultipleCSV(t *testing.T) { // TestInferSchema covers the INT / FLOAT / VARCHAR inference from a // CSV header + sample rows. Integer-only columns → INT, numeric (with -// a decimal) → FLOAT, anything else → VARCHAR(255). +// a decimal) → FLOAT, anything else → VARCHAR(n) sized by the longest value. func TestInferSchema(t *testing.T) { dir := t.TempDir() csv := writeFile(t, dir, "data.csv", "count,age,price,name\n1,30,9.99,alice\n2,40,19.5,bob\n") - schema, _, _, err := InferSchema(csv) + res, err := InferSchema(csv) if err != nil { t.Fatalf("InferSchema: %v", err) } + // VARCHAR(n) is sized by the longest sampled value (rune count), + // mirroring di#349 — "alice" (5) is the longest name. want := map[string]string{ "count": "INT", "age": "INT", "price": "FLOAT", - "name": "VARCHAR(255)", + "name": "VARCHAR(5)", } for col, typ := range want { - if schema[col] != typ { - t.Errorf("schema[%q] = %q, want %q (full: %v)", col, schema[col], typ, schema) + if res.Schema[col] != typ { + t.Errorf("schema[%q] = %q, want %q (full: %v)", col, res.Schema[col], typ, res.Schema) } } } -// TestInferSchema_EmptyColumnIsFloat: a column with no non-empty sampled -// value can't be typed from data; it's returned as a nullable FLOAT (not -// VARCHAR — an all-NULL VARCHAR is what the ingestor's string validator -// rejects) and reported in the `empty` return so the caller can warn. -func TestInferSchema_EmptyColumnIsFloat(t *testing.T) { +// TestInferSchema_EmptyColumnIsVarchar1: a column with no non-empty sampled +// value can't be typed from data; it comes back as VARCHAR(1) (mirroring +// the ingestor's all-missing rule) and is reported in the Empty list so +// it can be surfaced as a warning that the type is a guess with no evidence. +func TestInferSchema_EmptyColumnIsVarchar1(t *testing.T) { dir := t.TempDir() csv := writeFile(t, dir, "data.csv", "filled,empty\n1,\n2,\n") - schema, _, empty, err := InferSchema(csv) + res, err := InferSchema(csv) if err != nil { t.Fatalf("InferSchema: %v", err) } - if schema["empty"] != "FLOAT" { - t.Errorf("schema[empty] = %q, want FLOAT", schema["empty"]) + if res.Schema["empty"] != "VARCHAR(1)" { + t.Errorf("schema[empty] = %q, want VARCHAR(1)", res.Schema["empty"]) } - if schema["filled"] != "INT" { - t.Errorf("schema[filled] = %q, want INT", schema["filled"]) + if res.Schema["filled"] != "INT" { + t.Errorf("schema[filled] = %q, want INT", res.Schema["filled"]) } - if len(empty) != 1 || empty[0] != "empty" { - t.Errorf("empty = %v, want [empty]", empty) + if len(res.Empty) != 1 || res.Empty[0] != "empty" { + t.Errorf("empty = %v, want [empty]", res.Empty) } } @@ -195,24 +197,56 @@ func TestInferSchema_SkipsReservedColumns(t *testing.T) { dir := t.TempDir() csv := writeFile(t, dir, "data.csv", "id,feature_00,label\n1,1.5,0\n2,2.5,1\n") - schema, skipped, _, err := InferSchema(csv) + res, err := InferSchema(csv) if err != nil { t.Fatalf("InferSchema: %v", err) } - if _, present := schema["id"]; present { - t.Errorf("schema includes reserved column id: %v", schema) + if _, present := res.Schema["id"]; present { + t.Errorf("schema includes reserved column id: %v", res.Schema) } - if schema["feature_00"] != "FLOAT" || schema["label"] != "INT" { - t.Errorf("schema = %v, want feature_00:FLOAT, label:INT", schema) + if res.Schema["feature_00"] != "FLOAT" || res.Schema["label"] != "INT" { + t.Errorf("schema = %v, want feature_00:FLOAT, label:INT", res.Schema) } foundID := false - for _, s := range skipped { + for _, s := range res.Skipped { if s == "id" { foundID = true } } if !foundID { - t.Errorf("skipped = %v, want it to contain id", skipped) + t.Errorf("skipped = %v, want it to contain id", res.Skipped) + } +} + +// TestInferColumnType_TimezoneParity pins the datetime timezone behavior +// against data-ingestors' schema_inference.infer_column_type (di#349, +// verified against pandas 3.0.3). A column of tz-aware RFC3339 values is +// DATETIME only when the tokens share ONE timezone (all-naive, all-Z, or all +// the same offset). Mixed UTC offsets — or a mix of tz-aware and tz-naive — +// cannot form a single-timezone column, so the ingestor's _infer_datetime +// returns None and the column is VARCHAR; the CLI must mirror that rather than +// emit a tz-naive DATETIME that silently drops the per-row offset. The shared +// parity fixture is ASCII-only and carries no tz case, so this is pinned here. +func TestInferColumnType_TimezoneParity(t *testing.T) { + cases := []struct { + name string + values []string + want string + }{ + {"mixed_offsets", []string{"2024-01-02T00:00:00+00:00", "2024-01-02T00:00:00+05:00"}, "VARCHAR(25)"}, + {"uniform_offset", []string{"2024-01-02T00:00:00+05:00", "2024-01-03T00:00:00+05:00"}, "DATETIME"}, + {"all_zulu", []string{"2024-01-02T00:00:00Z", "2024-01-03T00:00:00Z"}, "DATETIME"}, + {"naive_datetime", []string{"2024-01-02T00:00:00", "2024-01-03T00:00:00"}, "DATETIME"}, + {"naive_plus_aware", []string{"2024-01-02T00:00:00", "2024-01-02T00:00:00+05:00"}, "VARCHAR(25)"}, + {"naive_plus_zulu", []string{"2024-01-02T00:00:00", "2024-01-02T00:00:00Z"}, "VARCHAR(20)"}, + {"space_naive", []string{"2024-01-02 13:45:00", "2024-01-03 08:00:00"}, "DATETIME"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := inferColumnType(c.values); got != c.want { + t.Errorf("inferColumnType(%v) = %q, want %q (di#349)", c.values, got, c.want) + } + }) } } diff --git a/internal/push/testdata/schema_inference_parity.json b/internal/push/testdata/schema_inference_parity.json new file mode 100644 index 00000000..b8429ce0 --- /dev/null +++ b/internal/push/testdata/schema_inference_parity.json @@ -0,0 +1,27 @@ +{ + "description": "Value-level parity contract for tabular schema inference (RFC-0002 §8.1). The data-ingestors `schema_inference.infer_column_type` is the source of truth; the Go CLI `InferSchema` (cli#185) must return the SAME type for each case. Backs the parity harness backend#1009. Each case: raw string column values -> expected SQL type. First-match-wins precedence: leading-zero-code -> bool -> int/bigint -> float -> date/datetime -> varchar. UNITS/CHARSET (must match on the Go side): VARCHAR(n) counts CHARACTERS (Unicode code points, = MySQL VARCHAR semantics) so Go must use utf8.RuneCountInString, NOT len(string) (bytes); integer/float matching is ASCII-only ([0-9]) so Unicode-digit and underscore-grouped tokens are text, not numbers.", + "sample_cap": 5000, + "cases": [ + {"name": "int", "values": ["1", "2", "3", "-4"], "expected": "INT"}, + {"name": "int_with_blank", "values": ["1", "", "3"], "expected": "INT"}, + {"name": "bigint_over_int32", "values": ["3000000000", "1"], "expected": "BIGINT"}, + {"name": "int64_overflow_is_text", "values": ["99999999999999999999999999", "1"], "expected": "VARCHAR(26)"}, + {"name": "float", "values": ["3.14", "2.0", "-0.5"], "expected": "FLOAT"}, + {"name": "float_scientific", "values": ["1e9", "2.5"], "expected": "FLOAT"}, + {"name": "float_and_int_mixed", "values": ["1", "2.5", "3"], "expected": "FLOAT"}, + {"name": "bool_yes_no", "values": ["yes", "no", "yes"], "expected": "BOOLEAN"}, + {"name": "bool_true_false", "values": ["true", "false", "TRUE"], "expected": "BOOLEAN"}, + {"name": "zero_one_is_int_not_bool", "values": ["0", "1", "1", "0"], "expected": "INT"}, + {"name": "zip_leading_zero", "values": ["007", "0012"], "expected": "VARCHAR(4)"}, + {"name": "zip_mixed_padded_and_unpadded", "values": ["00501", "90210", "12345"], "expected": "VARCHAR(5)"}, + {"name": "single_zero_is_int", "values": ["0", "1", "0"], "expected": "INT"}, + {"name": "date", "values": ["2024-01-02", "2024-03-15"], "expected": "DATE"}, + {"name": "datetime", "values": ["2024-01-02 13:45:00", "2024-01-03 08:00:00"], "expected": "DATETIME"}, + {"name": "numeric_id_is_int_not_date", "values": ["20240101", "20240102"], "expected": "INT"}, + {"name": "varchar_text", "values": ["apple", "banana"], "expected": "VARCHAR(6)"}, + {"name": "underscore_grouped_is_text", "values": ["1_000", "2_000"], "expected": "VARCHAR(5)"}, + {"name": "unicode_digits_are_text", "values": ["١٢٣", "٤٥٦"], "expected": "VARCHAR(3)"}, + {"name": "multibyte_varchar_is_char_count", "values": ["café", "naïve"], "expected": "VARCHAR(5)"}, + {"name": "all_missing_is_varchar1", "values": ["", "NA", "null"], "expected": "VARCHAR(1)"} + ] +} From 10e6d89ec89c6932c1e35b0d6d826f40dcf24494 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:36:39 +0200 Subject: [PATCH 10/22] fix(data ingest): mis-cased media folder next to labels.csv stays ambiguous, not confident tabular (#203) (#211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(data ingest): warn instead of silently ingesting a mis-cased media folder as a table (#203) A dataset with labels.csv plus a mis-cased media folder (Images/, Texts/, Sequences/) already stays ambiguous rather than sniffing confident tabular (#198's miscasedMarker guard) — but the ambiguous path gave no clue why. So the user saw only a blind "which is it?" prompt with no hint that their folder was just mis-cased. Carry a plain-language hint on the ambiguous FamilySniff for that case, naming the folder and the lowercase form it should have (e.g. "Images" → "images"), and surface it via ui.Printer.Warnf in resolveFamily before the family question. Reuses the same marker set the case-sensitive walk keys on (markerFold, formerly isMarkerFold, now returns the canonical marker). Extend TestSniffFamily to cover all three mis-cased markers (adds the Sequences/ case), assert each stays ambiguous AND carries a hint naming the rename, and pin that an unrelated subdir stays confident tabular with no hint. Co-Authored-By: Claude Opus 4.8 * fix(data ingest): mis-cased media sniff tracks the walk per-filesystem (#203) Address the code-review findings on the mis-cased-media guard. - Cross-platform false positive: the guard flagged a mis-cased "Images/" next to labels.csv as ambiguous and told the user to rename it — but on a case-insensitive filesystem (macOS APFS, Windows) the walk's own os.Lstat(/images) resolves that folder, so Discover already accepts the layout. The CLI preflight runs on the user's own machine, so a valid layout was reported broken with an unnecessary rename instruction. Now the sniff probes the literal lowercase marker path the walk keys on (markerResolves): if it resolves, treat the folder as the real marker (confident media, no hint); only when it doesn't resolve (case-sensitive FS, e.g. Linux) is it the genuine #203 footgun — stay ambiguous + hint. - Test coverage: add TestResolveFamily_SurfacesMiscasedHint, pinning that resolveFamily surfaces the advisory hint through the printer before asking the family plainly (the PR's headline behavior, previously exercised by no test). FS-aware, so on case-sensitive CI deleting the Warnf branch fails it. - Make the SniffFamily mis-cased test FS-aware to match the new behavior, and drop the dead `&& s.Confident` assertion (unreachable after the preceding not-confident guard). Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- internal/cli/interactive.go | 9 ++- internal/cli/interactive_test.go | 51 +++++++++++++++ internal/push/preview.go | 109 +++++++++++++++++++++++++------ internal/push/preview_test.go | 88 +++++++++++++++++-------- 4 files changed, 209 insertions(+), 48 deletions(-) diff --git a/internal/cli/interactive.go b/internal/cli/interactive.go index 70c1e279..fda85562 100644 --- a/internal/cli/interactive.go +++ b/internal/cli/interactive.go @@ -208,10 +208,17 @@ func runInteractive(p *ui.Printer, pr prompter, a *runDataIngestArgs, taskSet bo // prompts for the task afterward, so resolveFamily needn't report whether // it prompted the family question.) func resolveFamily(p *ui.Printer, pr prompter, path string) (push.Family, error) { - if s := push.SniffFamily(path); s.Confident { + s := push.SniffFamily(path) + if s.Confident { p.Successf("%s", s.Echo) return s.Family, nil } + if s.Hint != "" { + // Advisory only — e.g. a mis-cased media folder the walk won't see + // (#203). We still ask the family question; the hint just tells the + // user what looks off so they can fix the layout. + p.Warnf("%s", s.Hint) + } p.PromptHint("We couldn't tell the data type from what's there — which is it?") opts := push.FamilyNouns() ans, err := pr.Select("What kind of data is this?", diff --git a/internal/cli/interactive_test.go b/internal/cli/interactive_test.go index dc844eec..a0cce5ce 100644 --- a/internal/cli/interactive_test.go +++ b/internal/cli/interactive_test.go @@ -212,6 +212,57 @@ func TestRunInteractive_SniffIsHintNotLock(t *testing.T) { } } +// TestResolveFamily_SurfacesMiscasedHint pins the PR's headline behavior: an +// ambiguous sniff that carries an advisory hint (a mis-cased media folder next +// to labels.csv the walk can't see) has that hint surfaced through the printer +// before the family question — instead of silently ingesting the tree as a +// table (#203). The sniff tracks the walk, so behavior is FS-dependent; this +// asserts whichever branch applies on the machine it runs on. On a +// case-sensitive FS (Linux CI) the hint fires, so deleting resolveFamily's +// Warnf branch fails this test there. +func TestResolveFamily_SurfacesMiscasedHint(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "labels.csv"), + []byte("image_id,label\n001.jpg,cat\n"), 0o644); err != nil { + t.Fatalf("write labels.csv: %v", err) + } + if err := os.Mkdir(filepath.Join(dir, "Images"), 0o755); err != nil { + t.Fatalf("mkdir Images: %v", err) + } + + var buf bytes.Buffer + p := ui.New(&buf, ui.WithColor(false)) + f := &fakePrompter{answers: map[string]string{"What kind of data is this?": "image"}} + fam, err := resolveFamily(p, f, dir) + if err != nil { + t.Fatalf("resolveFamily: %v", err) + } + + if push.SniffFamily(dir).Hint != "" { + // Case-sensitive FS: the walk can't see Images/, so the sniff stays + // ambiguous with a rename hint. resolveFamily must print it, and still + // ask the family plainly (the hint is advisory, not a lock). + if !strings.Contains(buf.String(), "rename it and ingest again") { + t.Errorf("resolveFamily must surface the mis-cased rename hint; got:\n%s", buf.String()) + } + if !contains(f.asked, "What kind of data is this?") { + t.Errorf("hint is advisory — the family question must still be asked; asked=%v", f.asked) + } + } else { + // Case-insensitive FS: the walk resolves Images/, so the sniff is + // confident image — no false rename hint, no family question. + if fam != push.FamilyImage { + t.Errorf("family = %v, want image (walk resolves the mis-cased folder here)", fam) + } + if strings.Contains(buf.String(), "rename it and ingest again") { + t.Errorf("no false rename hint when the walk sees the folder; got:\n%s", buf.String()) + } + if contains(f.asked, "What kind of data is this?") { + t.Errorf("a confident sniff must not ask the family question; asked=%v", f.asked) + } + } +} + // TestRunInteractive_ExplicitTaskSkipsSniff: an explicit --task wins — no // sniff echo, no family question, no task picker. func TestRunInteractive_ExplicitTaskSkipsSniff(t *testing.T) { diff --git a/internal/push/preview.go b/internal/push/preview.go index f1248524..259925d0 100644 --- a/internal/push/preview.go +++ b/internal/push/preview.go @@ -25,6 +25,11 @@ type FamilySniff struct { // e.g. "Found a CSV table — this is tabular data." Empty when not // confident. Echo string + // Hint is an optional plain-language note for the ambiguous case, e.g. + // pointing out a mis-cased media folder ("Images/") that the walk won't + // see. Purely advisory — it never makes the sniff confident; the caller + // still asks the family question. Empty when there's nothing to add. + Hint string } // SniffFamily previews the family of the dataset at path by looking for @@ -34,12 +39,15 @@ type FamilySniff struct { // those (tabular), or a bare .csv file (tabular). It reads directory // entries only; it opens no files and validates nothing. // -// It never claims more than the matching Discover* would accept: the -// marker directories (images/, texts/, sequences/) and labels.csv are -// matched with the SAME literal, case-sensitive names the walk joins and -// Lstats — a mis-cased "Images/" is not the walk's marker, so it is not -// sniffed as confident image. Image / text are confident only when BOTH -// labels.csv AND the subdir are present, mirroring Discover / DiscoverText. +// It never claims more than the matching Discover* would accept: the marker +// directories (images/, texts/, sequences/) and labels.csv are probed with +// the SAME literal lowercase names the walk joins and Lstats. A mis-cased +// "Images/" is treated as the marker only when the walk's own +// os.Lstat(/images) resolves it — true on a case-insensitive filesystem +// (macOS, Windows), false on a case-sensitive one (Linux), so the sniff +// tracks the walk on the machine it runs on rather than guessing from the +// on-disk casing. Image / text are confident only when BOTH labels.csv AND +// the subdir are present, mirroring Discover / DiscoverText. // Tabular is confident on EXACTLY ONE CSV in a directory, mirroring // DiscoverTabular's findSingleCSV count rule — a directory with two or more // CSVs is a layout the tabular walk refuses, so the sniff must not @@ -95,13 +103,21 @@ func SniffFamily(path string) FamilySniff { // DiscoverTabular's EqualFold. var hasImages, hasTexts, hasSequences, hasLabels bool // miscasedMarker flags a subdir that matches a marker name only - // case-insensitively (e.g. "Images", "Texts") — a likely mis-cased - // media folder. The walk keys on the literal lowercase name, so such a - // dir is NOT a marker to it; but its lone labels.csv would otherwise - // fall through to the confident-tabular branch and get silently - // ingested as a table, images/texts dropped. When we see one, stay - // ambiguous and ask the family plainly. + // case-insensitively (e.g. "Images", "Texts") AND that the walk can't + // actually see. Whether the walk sees it is filesystem-dependent, so we + // don't guess from the on-disk casing — we probe the literal lowercase + // path the walk keys on (markerResolves, below). On a case-sensitive FS + // (Linux) a mis-cased dir is invisible to the walk, so its lone + // labels.csv would otherwise fall through to the confident-tabular + // branch and get silently ingested as a table, images/texts dropped — + // that's the footgun we flag. On a case-insensitive FS (macOS, Windows) + // the walk DOES resolve the mis-cased dir, so it's a real marker and we + // never set this. When set, stay ambiguous and ask the family plainly. miscasedMarker := false + // miscasedName / miscasedCanonical hold the first mis-cased dir we see + // and the lowercase marker it resembles, so the ambiguous return can + // name the likely rename (e.g. "Images" → "images") in its Hint. + var miscasedName, miscasedCanonical string csvCount := 0 for _, e := range entries { name := e.Name() @@ -114,8 +130,34 @@ func SniffFamily(path string) FamilySniff { case "sequences": hasSequences = true default: - if isMarkerFold(name) { - miscasedMarker = true + // The exact lowercase markers are matched by the cases above, so + // anything that EqualFolds a marker here is case-insensitive but + // NOT exact — e.g. "Images". Whether the walk actually SEES it is + // filesystem-dependent, so mirror the walk rather than guess: + // Discover keys on the literal lowercase name via + // os.Lstat(filepath.Join(dir, "images")). On a case-insensitive + // FS (macOS APFS, Windows) that resolves the mis-cased dir, so + // the walk accepts it — treat it as the real marker, exactly as + // Discover will (no false rename hint). Only when the lowercase + // literal does NOT resolve (a case-sensitive FS, e.g. Linux) is + // this a genuine footgun the walk can't see: flag it ambiguous + // and name the likely rename. + if m := markerFold(name); m != "" { + if markerResolves(abs, m) { + switch m { + case "images": + hasImages = true + case "texts": + hasTexts = true + case "sequences": + hasSequences = true + } + } else { + miscasedMarker = true + if miscasedName == "" { + miscasedName, miscasedCanonical = name, m + } + } } } continue @@ -160,22 +202,47 @@ func SniffFamily(path string) FamilySniff { // whose media folder was mis-cased must not masquerade as a table. return FamilySniff{Family: FamilyTabular, Confident: true, Echo: "Found a CSV table — this is tabular data."} + case miscasedMarker: + // A subdir matches a media marker case-insensitively but not exactly + // (e.g. "Images/"), AND markerResolves already confirmed the walk's + // literal lowercase path does NOT resolve on this filesystem — so the + // walk genuinely won't see this folder. An image/text layout here would + // otherwise look like a lone-CSV table and be silently ingested as one + // (#203). Stay ambiguous, but point at the likely rename so the user can + // fix the layout rather than just being asked a blind question. + return FamilySniff{Hint: fmt.Sprintf( + "Found a %q folder — image and text data use a lowercase folder like %q. "+ + "If that's your data folder, rename it and ingest again.", + miscasedName, miscasedCanonical)} default: return FamilySniff{} } } -// isMarkerFold reports whether name is one of the media-folder markers -// (images / texts / sequences) ignoring case. Used only to detect a -// mis-cased marker dir; the confident image/text branches still require an -// EXACT match, mirroring the walk's literal os.Lstat. -func isMarkerFold(name string) bool { +// markerResolves reports whether the walk's literal lowercase marker path +// (canonical is one of images / texts / sequences) resolves to a directory +// under dir — exactly the os.Lstat(filepath.Join(dir, canonical)) probe +// Discover / DiscoverText run. On a case-insensitive filesystem this resolves +// a mis-cased on-disk name (e.g. "Images"), so the sniff agrees with the walk +// that the folder IS the marker instead of emitting a false rename hint; on a +// case-sensitive filesystem it doesn't, exposing the real #203 footgun. +func markerResolves(dir, canonical string) bool { + fi, err := os.Lstat(filepath.Join(dir, canonical)) + return err == nil && fi.IsDir() +} + +// markerFold returns the media-folder marker (images / texts / sequences) +// that name matches ignoring case, or "" if none. Used only to detect a +// mis-cased marker dir and name the correct lowercase form in the hint; the +// confident image/text branches still require an EXACT match, mirroring the +// walk's literal os.Lstat. +func markerFold(name string) string { for _, m := range []string{"images", "texts", "sequences"} { if strings.EqualFold(name, m) { - return true + return m } } - return false + return "" } // PreviewLabelHeaders returns the column names of the CSV a label column diff --git a/internal/push/preview_test.go b/internal/push/preview_test.go index ae6bcd1c..b2816081 100644 --- a/internal/push/preview_test.go +++ b/internal/push/preview_test.go @@ -3,6 +3,7 @@ package push import ( "os" "path/filepath" + "strings" "testing" ) @@ -91,34 +92,66 @@ func TestSniffFamily(t *testing.T) { } }) - t.Run("mis-cased Images/ + labels.csv is ambiguous, NOT confident tabular", func(t *testing.T) { - // Discover Lstats the literal "images"; a mis-cased "Images/" is not - // its marker. The sniff must not claim confident image — but it must - // ALSO not fall through to confident tabular, or the lone labels.csv - // of a mis-cased image layout would be silently ingested as a table - // (cli#203). It stays ambiguous so the flow asks the family plainly. - dir := t.TempDir() - writePrev(t, filepath.Join(dir, "labels.csv"), "image_id,label\n1.jpg,c\n") - if err := os.Mkdir(filepath.Join(dir, "Images"), 0o755); err != nil { - t.Fatal(err) - } - if s := SniffFamily(dir); s.Confident { - t.Fatalf("mis-cased Images/ + labels.csv must be ambiguous, got %+v", s) - } - }) + // The mis-cased-media footgun (#203): labels.csv next to a media folder + // whose name matches a marker case-insensitively but not exactly. The + // sniff mirrors the walk, which keys on the literal lowercase name via + // os.Lstat — so behavior is filesystem-dependent, and each subtest asserts + // the branch that actually applies on the FS it runs on: + // - case-SENSITIVE FS (Linux CI): the walk can't see the folder, so the + // lone labels.csv would otherwise fall through to confident tabular and + // the media be silently ingested away. The sniff must stay ambiguous + // (NOT confident — that covers the confident-tabular footgun) and carry + // a rename hint naming both the folder and its lowercase form. + // - case-INSENSITIVE FS (macOS APFS, Windows): the walk resolves the + // mis-cased folder under its lowercase name, so the layout is valid. + // The sniff must AGREE — confident media, no false rename hint telling + // the user to fix a layout that already works (#203 cross-platform). + for _, tc := range []struct { + folder, canonical string + wantFamily Family + }{ + {"Images", "images", FamilyImage}, + {"Texts", "texts", FamilyText}, + {"Sequences", "sequences", FamilyText}, + } { + tc := tc + t.Run("mis-cased "+tc.folder+"/ + labels.csv tracks the walk", func(t *testing.T) { + dir := t.TempDir() + writePrev(t, filepath.Join(dir, "labels.csv"), "id,label\n1,c\n") + if err := os.Mkdir(filepath.Join(dir, tc.folder), 0o755); err != nil { + t.Fatal(err) + } + // The walk's own probe: does the literal lowercase marker resolve? + fi, err := os.Lstat(filepath.Join(dir, tc.canonical)) + walkSeesIt := err == nil && fi.IsDir() - t.Run("mis-cased Texts/ + labels.csv is ambiguous, NOT confident tabular", func(t *testing.T) { - dir := t.TempDir() - writePrev(t, filepath.Join(dir, "labels.csv"), "text_id,label\n1.txt,c\n") - if err := os.Mkdir(filepath.Join(dir, "Texts"), 0o755); err != nil { - t.Fatal(err) - } - if s := SniffFamily(dir); s.Confident { - t.Fatalf("mis-cased Texts/ + labels.csv must be ambiguous, got %+v", s) - } - }) + s := SniffFamily(dir) + if walkSeesIt { + // Case-insensitive FS: the folder IS the marker to the walk. + if !s.Confident || s.Family != tc.wantFamily { + t.Fatalf("case-insensitive FS: mis-cased %s/ should sniff confident %v, got %+v", tc.folder, tc.wantFamily, s) + } + if s.Hint != "" { + t.Fatalf("no false rename hint when the walk resolves %s/, got %q", tc.folder, s.Hint) + } + return + } + // Case-sensitive FS: the real #203 footgun. Not confident at all — + // that single check covers the confident-tabular masquerade — plus a + // rename hint that names both the folder and its lowercase form. + if s.Confident { + t.Fatalf("case-sensitive FS: mis-cased %s/ + labels.csv must be ambiguous, got %+v", tc.folder, s) + } + if s.Hint == "" { + t.Fatalf("mis-cased %s/ should carry a rename hint, got %+v", tc.folder, s) + } + if !strings.Contains(s.Hint, tc.folder) || !strings.Contains(s.Hint, tc.canonical) { + t.Fatalf("hint should name both %q and %q, got %q", tc.folder, tc.canonical, s.Hint) + } + }) + } - t.Run("single csv + unrelated subdir stays confident tabular", func(t *testing.T) { + t.Run("single csv + unrelated subdir stays confident tabular, no hint", func(t *testing.T) { // The mis-cased guard must be narrow: a subdir that is NOT a marker // name (case-insensitively) — a stray backup/ etc. — must not derail // the confident-tabular sniff, since DiscoverTabular ignores it too. @@ -131,6 +164,9 @@ func TestSniffFamily(t *testing.T) { if !s.Confident || s.Family != FamilyTabular { t.Fatalf("single csv + unrelated subdir should stay confident tabular, got %+v", s) } + if s.Hint != "" { + t.Fatalf("an unrelated subdir must not trigger a mis-cased hint, got %q", s.Hint) + } }) t.Run("images/ without labels.csv is not confident image", func(t *testing.T) { From f1afe2f8bf65d32a34b3a340a9ddab3906f704e1 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:08:01 +0200 Subject: [PATCH 11/22] fix: harden login env-validation, Inf/NaN schema inference, and log-scanner buffer (bug-hunt MED) (#208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(auth): reject unknown --env/$CLIENT_ENV at login instead of silently using prod BaseURL falls unknown/typo env values back to prod (a lenient library default), so `login --env staging` or `CLIENT_ENV=prd` silently targeted production AND persisted it as the active session env for every later command — the class behind earlier dev-vs-prod confusion. login PICKS and persists the session env, so a typo must fail there. Add api.IsKnownEnv (dev/stg/prod, case-insensitive) and validate the resolved env at runLogin entry, before any network call. ResolveEnv still maps empty->prod, so the no-flag default is unaffected. BaseURL's unknown->prod fallback is deliberately unchanged (TestBaseURL asserts it). Co-Authored-By: Claude Opus 4.8 * fix(submit): raise log-scanner buffer to 16 MB so tqdm progress can't force a false exit 9 tqdm (a data-ingestors dep) redraws its progress bar with \r and no \n, so a whole ingestion phase's redraws are one newline-delimited "line" that grows for the life of the run. Past 1 MB the display scanner returned bufio.ErrTooLong, cutting the log stream mid-run; a still-running Job then couldn't be confirmed terminal in the 30s finalJobStatus poll, so watch returned a false exit 9 on a healthy large ingestion — exactly the case the 1h JobWatchTimeout targets. The parser is fed via the TeeReader, not the scanner, so this cap only ever bounded the DISPLAY line and never the verdict. Raise it to 16 MB (clears a fast ~10/s hour of redraws with headroom; the 1h cap bounds accumulation). The buffer grows on demand, so ordinary log lines still cost 64 KB. Co-Authored-By: Claude Opus 4.8 * fix(submit): drain past ErrTooLong so a giant tqdm line can't force a false exit 9 (review) Addresses @saadqbal's review on #208: the 16 MB buffer bump only MOVED the false-exit-9 threshold, it didn't close it. The tee is pulled only by the DISPLAY scanner, so when a line trips ErrTooLong the scan loop exits, the tee stops being read, and the parser never sees the rest of the stream (the closing banner) → streamFailed && outcome==Unknown → a false exit 9 on a healthy run. A long enough single '\r'-line (> the buffer) still breaks it. Class-level fix (his suggestion): keep draining past ErrTooLong. Extracted the display/parse loop into streamDisplayAndParse; on ErrTooLong it drains the rest of the stream THROUGH the tee (io.Copy to io.Discard) so the parser still sees the banner, and it is NOT fatal — the Job status poll is the verdict's source of truth. Genuine read failures (network drop, ctx cancel) still propagate. Corrected the now-wrong "cap never affects the verdict" comment; kept 16 MB as a generous display headroom (the drain is the correctness guarantee). New tests (the #3 no-test gap Asad noted): an oversized '\r'-line + the real ingestor banner → the parser still resolves the summary (no false exit 9); and a genuine read error still propagates. Co-Authored-By: Claude Opus 4.8 * test(push): pin Inf/NaN → not FLOAT against the di#349 inference #208's fix #2 (demote Inf/NaN columns to VARCHAR) is superseded by #185/#210: the di#349 floatRE grammar pre-screens the token before ParseFloat, so "inf"/"Infinity"/"NaN" already fall through to VARCHAR. Dropped the redundant production change on rebase; kept the intent as a regression test, since no parity-fixture case covers non-finite. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- internal/api/client.go | 15 ++++++ internal/api/client_test.go | 17 ++++++ internal/cli/auth.go | 9 ++++ internal/push/tabular_test.go | 21 ++++++++ internal/submit/watch.go | 76 +++++++++++++++++++-------- internal/submit/watch_display_test.go | 67 +++++++++++++++++++++++ 6 files changed, 182 insertions(+), 23 deletions(-) create mode 100644 internal/submit/watch_display_test.go diff --git a/internal/api/client.go b/internal/api/client.go index cc7e958b..15a95bd5 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -106,6 +106,21 @@ func ResolveEnv(explicit string) string { return EnvProd } +// IsKnownEnv reports whether env is one of the recognized backends (dev/stg/prod, +// case-insensitively). Callers that let a human PICK the env (e.g. `login`) use it +// to reject a typo up front — BaseURL deliberately falls unknown values back to +// prod (a lenient library default), so without this a `--env staging`/`prd` typo +// would silently target production. Empty is NOT known here: resolve first +// (ResolveEnv turns empty into the prod default), then validate the result. +func IsKnownEnv(env string) bool { + switch strings.ToLower(env) { + case EnvDev, EnvStg, EnvProd: + return true + default: + return false + } +} + // Client talks to the backend REST API. Token (the user token from login) is // optional: the device-flow endpoints are unauthenticated; provisioning calls // set it. diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 11ba575a..2a1f107a 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -41,6 +41,23 @@ func TestResolveEnv(t *testing.T) { } } +func TestIsKnownEnv(t *testing.T) { + known := []string{"dev", "stg", "prod", "DEV", "Prod"} // case-insensitive + for _, env := range known { + if !IsKnownEnv(env) { + t.Errorf("IsKnownEnv(%q) = false, want true", env) + } + } + // Typos and the unknown values BaseURL would silently route to prod + // must be rejected so `login` fails instead of persisting a prod session. + unknown := []string{"staging", "prd", "production", "development", "", " "} + for _, env := range unknown { + if IsKnownEnv(env) { + t.Errorf("IsKnownEnv(%q) = true, want false", env) + } + } +} + func TestRequestDeviceCode(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/device/code" || r.Method != http.MethodPost { diff --git a/internal/cli/auth.go b/internal/cli/auth.go index f408aca7..c8c65cd3 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -56,6 +56,15 @@ func runLogin(ctx context.Context, p *ui.Printer, envFlag string) error { return &exitError{code: 1, err: err} } env := api.ResolveEnv(envFlag) + // login PICKS the session env and persists it (cfg.CurrentEnv below), so a + // typo must fail HERE, not silently resolve to prod. BaseURL's lenient + // unknown→prod fallback would otherwise route `--env staging` / `CLIENT_ENV=prd` + // to production and store it as the active env for every later command. + if !api.IsKnownEnv(env) { + return &exitError{code: 1, err: fmt.Errorf( + "unknown backend environment %q — valid values are dev, stg, prod (default). "+ + "Check --env / $CLIENT_ENV", env)} + } client := newAPIClient(env) p.Detailf("backend %s — requesting a device code …", client.BaseURL) diff --git a/internal/push/tabular_test.go b/internal/push/tabular_test.go index f9e3440b..ce10180f 100644 --- a/internal/push/tabular_test.go +++ b/internal/push/tabular_test.go @@ -167,6 +167,27 @@ func TestInferSchema(t *testing.T) { } } +// TestInferSchema_NonFiniteIsNotFloat: Go's strconv.ParseFloat accepts +// "Inf"/"Infinity"/"NaN", but the ingestor's FLOAT cast rejects a non-finite +// value — so a column carrying one must NOT infer FLOAT (that would hand the +// cluster a schema it only refuses AFTER the upload). The di#349 float grammar +// (floatRE) pre-screens the token before ParseFloat, so "inf"/"NaN" fall +// through to VARCHAR and preflight matches what the cluster will accept. No +// parity-fixture case covers this, so pin it here. +func TestInferSchema_NonFiniteIsNotFloat(t *testing.T) { + dir := t.TempDir() + csv := writeFile(t, dir, "data.csv", + "reading,note\n1.5,ok\ninf,spike\nNaN,dropout\n") + res, err := InferSchema(csv) + if err != nil { + t.Fatalf("InferSchema: %v", err) + } + // Longest of 1.5/inf/NaN is 3 runes → VARCHAR(3); the point is it is NOT FLOAT. + if got := res.Schema["reading"]; got != "VARCHAR(3)" { + t.Errorf("schema[reading] = %q, want VARCHAR(3) (Inf/NaN must not infer FLOAT)", got) + } +} + // TestInferSchema_EmptyColumnIsVarchar1: a column with no non-empty sampled // value can't be typed from data; it comes back as VARCHAR(1) (mirroring // the ingestor's all-missing rule) and is reported in the Empty list so diff --git a/internal/submit/watch.go b/internal/submit/watch.go index bc5a6ab2..6f8dfa66 100644 --- a/internal/submit/watch.go +++ b/internal/submit/watch.go @@ -463,29 +463,16 @@ func streamPodLogsAndParse( // io.Copy would also work but would buffer chunks at the // transport layer, making the output feel laggy on a fast // ingestion. - scanner := bufio.NewScanner(tee) - // Default scanner buffer is 64 KB per line — fine for log - // lines but bump to 1 MB to handle the (rare) case where a - // single ingestion-error stacktrace has a multi-KB Python - // traceback line. - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - for scanner.Scan() { - line := scanner.Bytes() - // Print the line + a newline (scanner strips the trailing - // '\n'). errcheck-friendly: we discard the writer error - // because the exit code is the customer-facing contract. - _, _ = out.Write(line) - _, _ = out.Write([]byte("\n")) - } - if err := scanner.Err(); err != nil { - // EOF is normal end-of-stream; other errors (network drop - // mid-stream, ctx cancel) get propagated. - if !errors.Is(err, io.EOF) { - // Flush any buffered partial line before returning so - // the parser sees content even on mid-line failure. - parser.FlushLine() - return parser.Result(), err - } + // Pull the whole stream through the tee (which feeds the summary parser) + // while rendering it line-by-line for the customer. An over-long DISPLAY + // line (a tqdm '\r'-redraw burst) is drained, not fatal — see + // streamDisplayAndParse. + if err := streamDisplayAndParse(tee, out, displayLineMax); err != nil { + // A genuine mid-stream failure (network drop, ctx cancel). Flush any + // buffered partial line so the parser sees content even on a mid-line + // failure, then propagate. + parser.FlushLine() + return parser.Result(), err } // Flush at the end of stream too. A Pod that exited without // a trailing newline on its final stdout write would otherwise @@ -495,6 +482,49 @@ func streamPodLogsAndParse( return parser.Result(), nil } +// displayLineMax caps the per-line DISPLAY buffer (grows on demand from 64 KB, +// so ordinary log lines still cost 64 KB). A tqdm progress "line" — many '\r' +// redraws with no '\n' — grows for the life of the run; the generous cap lets +// the common case render fully, and streamDisplayAndParse drains anything past +// it rather than failing. The 1h JobWatchTimeout bounds accumulation. +const displayLineMax = 16 * 1024 * 1024 + +// streamDisplayAndParse renders r line-by-line to out for the customer. The +// caller wraps the log stream in an io.TeeReader that ALSO feeds the summary +// parser, and this is the ONLY place the tee is pulled — so the function's real +// job is to pull the WHOLE stream through, whatever the display does with it. +// +// A tqdm progress "line" (many '\r' redraws, no '\n') can outgrow maxLine. That +// must NOT stop the pull: if it did, the tee would stop feeding the parser and +// the closing banner would never be parsed, so watch would report a false exit +// 9 on a healthy run (raising maxLine only postpones the threshold). On +// bufio.ErrTooLong we therefore keep draining the rest of the stream — +// discarding only the oversized DISPLAY line — so the parser still sees the +// banner; the Job status poll is the verdict's real source of truth. Returns a +// non-nil error only on a genuine read failure (network drop, ctx cancel). +func streamDisplayAndParse(r io.Reader, out io.Writer, maxLine int) error { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), maxLine) + for scanner.Scan() { + // scanner strips the trailing '\n'; re-add it. errcheck-friendly: the + // write error is discarded because the exit code is the contract. + _, _ = out.Write(scanner.Bytes()) + _, _ = out.Write([]byte("\n")) + } + err := scanner.Err() + if err == nil || errors.Is(err, io.EOF) { + return nil + } + if errors.Is(err, bufio.ErrTooLong) { + // Keep draining THROUGH r (the tee) so the parser still sees the banner. + if _, derr := io.Copy(io.Discard, r); derr != nil { + return derr + } + return nil + } + return err +} + // parserWriter adapts a SummaryParser into an io.Writer for use // with io.TeeReader. The TeeReader writes everything to its // secondary sink as bytes flow through; the parser's Feed method diff --git a/internal/submit/watch_display_test.go b/internal/submit/watch_display_test.go new file mode 100644 index 00000000..6bf86c96 --- /dev/null +++ b/internal/submit/watch_display_test.go @@ -0,0 +1,67 @@ +package submit + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" +) + +// errAfterReader yields its data, then returns err on the next Read — a genuine +// mid-stream read failure (network drop / ctx cancel), distinct from io.EOF. +type errAfterReader struct { + data []byte + err error + pos int +} + +func (r *errAfterReader) Read(p []byte) (int, error) { + if r.pos >= len(r.data) { + return 0, r.err + } + n := copy(p, r.data[r.pos:]) + r.pos += n + return n, nil +} + +// TestStreamDisplayAndParse_DrainsPastOversizedLineSoParserSeesBanner pins the +// #208-review fix: an over-long tqdm-style progress "line" (many '\r' redraws, +// no '\n') that outgrows the display buffer, immediately followed by the real +// closing banner. Because the tee is pulled ONLY by the display scanner, a +// naive ErrTooLong bail would stop the parser from ever seeing the banner → +// watch returns a false exit 9 on a healthy run. The drain-past-ErrTooLong must +// keep pulling so the parser still resolves the summary. +func TestStreamDisplayAndParse_DrainsPastOversizedLineSoParserSeesBanner(t *testing.T) { + oversized := strings.Repeat("\rprocessing... ", 500) // ~7 KB, no '\n' + stream := strings.NewReader(oversized + realIngestorBanner) + parser := NewSummaryParser() + tee := io.TeeReader(stream, parserWriter{parser: parser}) + + var out bytes.Buffer + // Tiny cap so the oversized line trips ErrTooLong (production uses 16 MB). + if err := streamDisplayAndParse(tee, &out, 1024); err != nil { + t.Fatalf("an over-long DISPLAY line must not be fatal; got: %v", err) + } + parser.FlushLine() + if got := parser.Result().InsertedRecords; got != 1200 { + t.Fatalf( + "parser missed the banner after the oversized line (false exit 9): "+ + "InsertedRecords=%d, want 1200", + got, + ) + } +} + +// TestStreamDisplayAndParse_GenuineReadErrorPropagates: a real mid-stream read +// failure (not ErrTooLong, not EOF) stays fatal — the drain path must not +// swallow genuine stream errors. +func TestStreamDisplayAndParse_GenuineReadErrorPropagates(t *testing.T) { + want := errors.New("connection reset by peer") + r := &errAfterReader{data: []byte("some log line\n"), err: want} + + var out bytes.Buffer + if err := streamDisplayAndParse(r, &out, 1024); !errors.Is(err, want) { + t.Fatalf("a genuine read error should propagate; got: %v", err) + } +} From f619db5cd18d0ffeb0357d0e149d7deb1daa8812 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:17:02 +0200 Subject: [PATCH 12/22] fix(delete): don't brick local teardown when the revoke isn't a 403 (#205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(delete): don't brick local teardown when the server-side revoke isn't a 403 runDelete treated the credential revoke as a hard gate: only HTTP 403 was special-cased (ask-an-admin); every OTHER RevokeClient error returned exitError{code:1} and short-circuited the entire offboard — helm uninstall, k3d teardown, image prune, ~/.tracebloc wipe, and self-remove never ran. So a stale/ wrong-account active-client pointer (account-scoped 404), a backend predating the /edge-device//revoke route (404), or a transient network/5xx blip left the machine fully installed with no escape (--force only skips the online guard). That contradicts the same function's online-guard, which already treats 5xx/429/network as "warn and continue — the teardown is the real gate," and its own comment anticipating a 404 revoke. Fix: non-403 revoke failures now warn and continue into the (offline-capable) local teardown, mirroring the online guard. 403 still routes to ask-an-admin (unchanged, still tested). "Revoked …" is printed only on actual success; the warn path says the credential may still be live (revoke from the dashboard; the orphan reaper backend#970 sweeps a never-torn-down record later). Test: TestDelete_RevokeNon403_ContinuesTeardown (404 revoke → teardown still runs, honest warning, no false "revoked" claim). Existing TestDelete_RevokeForbidden (403 → ask-an-admin) unchanged. Full suite green; gofmt -s / errcheck / ineffassign clean. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(delete): honest offboard summary + treat 426/401 revoke as terminal Addresses @saadqbal's #205 review: - 426 revoke now fails fast with the upgrade prompt (mirrors the pre-offboard guard) instead of warn-and-continue into a teardown the backend can't process. - 401 revoke now fails fast to re-login. The --force footgun: with --force the online guard is skipped, so an expired session used to silently tear the machine down while leaving a live credential; now it aborts to sign-in. - Track whether the server-side revoke actually succeeded; the closing summary is honest on BOTH axes (revoke status x teardown status) and no longer claims the credential is revoked / the machine disconnected when the revoke failed. - Fix the now-false "credential is already revoked" comment on the teardown block. - Tests: 404 + no-namespace honest closing (the degraded+revoke-failed case the old test never reached), plus 426 and 401 revoke fail-fast. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 (1M context) --- internal/cli/delete.go | 77 ++++++++++++++++----- internal/cli/delete_test.go | 132 ++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 16 deletions(-) diff --git a/internal/cli/delete.go b/internal/cli/delete.go index 4d85e85b..754b7b11 100644 --- a/internal/cli/delete.go +++ b/internal/cli/delete.go @@ -193,21 +193,57 @@ func runDelete(ctx context.Context, p *ui.Printer, pr prompter, o deleteOpts) er // 1. Revoke the machine credential server-side (POST /edge-device//revoke, // §7.10 / C.6). This kills the credential without deleting the row — the - // retained history in scope 2 stays intact. A 403 → ask-an-admin. + // retained history in scope 2 stays intact. + revoked := true if rerr := client.RevokeClient(ctx, id); rerr != nil { + revoked = false + // A 426 (CLI too old) won't recover by continuing — the whole offboard talks + // to the same backend — so fail fast with the upgrade message rather than + // tear the machine down against a backend that can't process the revoke. + // Mirrors the pre-offboard guard above (which treats 426 as terminal). + var ue *api.UpgradeRequiredError + if errors.As(rerr, &ue) { + return &exitError{code: 1, err: rerr} + } var ae *api.APIError - if errors.As(rerr, &ae) && ae.StatusCode == http.StatusForbidden { - return askAnAdmin(ctx, p, client, "offboard this machine", "offboarding") + if errors.As(rerr, &ae) { + switch ae.StatusCode { + case http.StatusForbidden: + // A 403 is a genuine authorization decision — you can't revoke a + // client you don't manage; route to ask-an-admin (unchanged). + return askAnAdmin(ctx, p, client, "offboard this machine", "offboarding") + case http.StatusUnauthorized: + // A 401 means the signed-in session is expired/revoked. With --force + // the online-guard above is skipped, so DON'T silently tear the machine + // down while a live credential remains — fail fast and point at sign-in, + // mirroring the pre-offboard guard's 401 handling. + return &exitError{code: 1, err: errors.New( + "tracebloc rejected your credentials — run `tracebloc login`, then retry `tracebloc delete`")} + } } - return &exitError{code: 1, err: fmt.Errorf("revoking the machine credential: %w", rerr)} + // Any OTHER revoke failure must NOT block the local teardown. Removing + // tracebloc from THIS machine (helm uninstall, cluster teardown, on-host + // data + config wipe, self-remove) is offline-capable and is the command's + // primary job — it can't be held hostage to a best-effort remote call. This + // hits on a 404 (a stale/wrong-account active-client pointer, or a backend + // predating the /revoke route), a transient network/5xx error, etc. Warn and + // continue, mirroring the online-guard above. The credential may remain live + // server-side (revoked stays false), so the closing summary says so: the user + // can revoke it from the dashboard, and the orphan reaper (backend#970) sweeps + // a never-torn-down record later. + p.Hintf("Couldn't revoke the credential server-side (%v) — continuing with local teardown. "+ + "The credential may still be live on tracebloc; revoke it from the dashboard if needed.", rerr) + } else { + p.Successf("Revoked this machine's credential (client %q kept on tracebloc as a record).", name) } - p.Successf("Revoked this machine's credential (client %q kept on tracebloc as a record).", name) - // The teardown steps below are best-effort (the credential is already revoked), - // but a step that leaves real state behind — a live release, the local cluster, - // or on-host data — must NOT be papered over by the final success line. Track it - // so the closing message tells the truth (image reclaim is pure disk cleanup, so - // it's intentionally excluded — its own warning already surfaces it). + // The teardown steps below are best-effort. (The credential is revoked when the + // server-side revoke above succeeded; on a best-effort revoke failure it may + // still be live — the closing summary reports which.) A step that leaves real + // state behind — a live release, the local cluster, or on-host data — must NOT be + // papered over by the final success line. Track it so the closing message tells + // the truth (image reclaim is pure disk cleanup, so it's intentionally excluded — + // its own warning already surfaces it). degraded := false // Clear the local enrollment pointer and persist it IMMEDIATELY — before the @@ -283,14 +319,23 @@ func runDelete(ctx context.Context, p *ui.Printer, pr prompter, o deleteOpts) er } p.Newline() - // The credential is revoked either way, so the machine can no longer connect — - // but only claim a clean offboard when the teardown actually completed. If a - // step left real state behind, say so instead of printing an unqualified success. - if degraded { + // Be honest on BOTH axes: whether the server-side revoke succeeded (revoked) and + // whether the local teardown completed (!degraded). Neither is guaranteed — the + // revoke is best-effort on a non-terminal failure, and the teardown steps are + // best-effort — so only claim "revoked / no longer connected" when it's true. + switch { + case revoked && !degraded: + p.Successf("Offboarded %q. This machine is no longer connected to tracebloc.", name) + case revoked && degraded: p.Warnf("Offboarded %q: the machine credential is revoked, so it can no longer connect to tracebloc — "+ "but some cleanup above didn't complete. Finish the flagged steps by hand.", name) - } else { - p.Successf("Offboarded %q. This machine is no longer connected to tracebloc.", name) + case !revoked && !degraded: + p.Warnf("Tore down %q on this machine. The server-side revoke didn't complete, so the credential may "+ + "still be live on tracebloc — revoke it from the dashboard if needed (the orphan reaper sweeps it otherwise).", name) + default: // !revoked && degraded + p.Warnf("Tore down %q on this machine, but some cleanup above didn't complete and the server-side revoke "+ + "didn't complete — the credential may still be live on tracebloc (revoke it from the dashboard). "+ + "Finish the flagged steps by hand.", name) } return nil } diff --git a/internal/cli/delete_test.go b/internal/cli/delete_test.go index fbfab450..f70bb56c 100644 --- a/internal/cli/delete_test.go +++ b/internal/cli/delete_test.go @@ -193,6 +193,138 @@ func TestDelete_Yes_FullSequence(t *testing.T) { assertRemoved(t, fn, filepath.Join(filepath.Dir(exe), "tb")) } +// (b') A non-403 revoke failure (a 404 from a stale/wrong-account pointer or a +// backend predating /revoke, a transient network/5xx error, …) must NOT brick the +// offboard: local teardown is the command's real job and runs anyway. Only a 403 +// (a genuine authz denial) aborts to ask-an-admin (covered separately). +func TestDelete_RevokeNon403_ContinuesTeardown(t *testing.T) { + withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/edge-device/": + _, _ = w.Write([]byte(`[{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}]`)) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/revoke"): + w.WriteHeader(http.StatusNotFound) // 404: stale pointer / backend predates /revoke + default: + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + }) + setActiveForDelete(t, "5", "gpu-box-01", "gpu-box-01") + exe := writeBinaryWithTBAlias(t) + fn := &fakeNodeboot{executable: exe} + fn.install(t) + + var out bytes.Buffer + if err := runDelete(context.Background(), ui.New(&out), nil, deleteOpts{yes: true}); err != nil { + t.Fatalf("a non-403 revoke error must not abort the offboard, got: %v", err) + } + // Teardown still ran despite the failed revoke. + for _, want := range []string{"uninstall:gpu-box-01", "teardown:" + nodeboot.ClusterName, "prune"} { + found := false + for _, c := range fn.calls { + if c == want { + found = true + break + } + } + if !found { + t.Errorf("teardown step %q must run even when revoke fails; calls: %v", want, fn.calls) + } + } + // Honest messaging: warn about the failed revoke, and do NOT claim success. + s := out.String() + if !strings.Contains(s, "Couldn't revoke the credential server-side") { + t.Errorf("want a warning about the failed revoke, got:\n%s", s) + } + if strings.Contains(s, "Revoked this machine's credential") { + t.Errorf("must NOT claim the credential was revoked when it wasn't:\n%s", s) + } +} + +// (b”) A non-403 revoke failure that ALSO hits a degraded teardown step (here: no +// namespace → the uninstall is skipped) must tell the truth on BOTH axes — it must +// NOT claim the credential was revoked or the machine disconnected. Regression for +// the overclaim: the old closing hardcoded "the credential is revoked, so it can no +// longer connect" even when the revoke had failed. +func TestDelete_RevokeNon403_Degraded_HonestClosing(t *testing.T) { + withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/edge-device/": + _, _ = w.Write([]byte(`[{"id":5,"first_name":"gpu-box-01","namespace":"gpu-box-01","status":0}]`)) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/revoke"): + w.WriteHeader(http.StatusNotFound) // 404: best-effort revoke fails + default: + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + }) + setActiveForDelete(t, "5", "gpu-box-01", "") // no namespace → uninstall skipped → degraded + fn := &fakeNodeboot{executable: filepath.Join(t.TempDir(), "tracebloc")} + fn.install(t) + + var out bytes.Buffer + if err := runDelete(context.Background(), ui.New(&out), nil, deleteOpts{yes: true}); err != nil { + t.Fatalf("a best-effort revoke failure must not abort the offboard, got: %v", err) + } + s := out.String() + if strings.Contains(s, "no longer connect") || strings.Contains(s, "credential is revoked") { + t.Errorf("closing must NOT claim the credential was revoked / the machine disconnected when revoke failed:\n%s", s) + } + if !strings.Contains(s, "revoke didn't complete") { + t.Errorf("closing should say the server-side revoke didn't complete:\n%s", s) + } + if !strings.Contains(s, "still be live") { + t.Errorf("closing should point at the possibly-live credential:\n%s", s) + } +} + +// A 426 from the REVOKE call (reached under --force, which skips the online guard) +// must fail fast with the upgrade message rather than warn-and-continue into a +// teardown against a backend that can't process the revoke — matching the guard. +func TestDelete_RevokeUpgradeRequired_FailsFast(t *testing.T) { + withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/revoke") { + w.WriteHeader(http.StatusUpgradeRequired) // 426 + _, _ = w.Write([]byte(`{"error":"upgrade_required","min_version":"1.2.3"}`)) + } + }) + setActiveForDelete(t, "5", "gpu-box-01", "gpu-box-01") + fn := &fakeNodeboot{executable: filepath.Join(t.TempDir(), "tracebloc")} + fn.install(t) + + var out bytes.Buffer + err := runDelete(context.Background(), ui.New(&out), nil, deleteOpts{yes: true, force: true}) + if err == nil || !strings.Contains(err.Error(), "too old") { + t.Fatalf("a 426 revoke must fail fast with the upgrade message, got: %v", err) + } + if len(fn.calls) != 0 { + t.Errorf("no teardown after a 426 revoke, got: %v", fn.calls) + } +} + +// A 401 from the REVOKE call (reached under --force) means the session is expired/ +// revoked; fail fast and point at re-login rather than tear the machine down while a +// live credential remains. Without this, --force + an expired token silently wipes +// the machine and leaves the credential alive. +func TestDelete_RevokeUnauthorized_FailsFast(t *testing.T) { + withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/revoke") { + w.WriteHeader(http.StatusUnauthorized) // 401 + _, _ = w.Write([]byte(`{"detail":"invalid token"}`)) + } + }) + setActiveForDelete(t, "5", "gpu-box-01", "gpu-box-01") + fn := &fakeNodeboot{executable: filepath.Join(t.TempDir(), "tracebloc")} + fn.install(t) + + var out bytes.Buffer + err := runDelete(context.Background(), ui.New(&out), nil, deleteOpts{yes: true, force: true}) + if err == nil || !strings.Contains(err.Error(), "tracebloc login") { + t.Fatalf("a 401 revoke must fail fast with a sign-in hint, got: %v", err) + } + if len(fn.calls) != 0 { + t.Errorf("no teardown after a 401 revoke, got: %v", fn.calls) + } +} + // (c) --keep-data spares ~/.tracebloc but still uninstalls + removes the binary. func TestDelete_KeepData_SparesDataDir(t *testing.T) { withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { From 8c06ab8776eab14feb2a7d6a6f01c6009efbc4c1 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:17:48 +0200 Subject: [PATCH 13/22] fix(preflight): resolve labels.csv filename by column name, not position (#207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(preflight): resolve labels.csv filename by column name, not position CrossCheckLabels (image_classification preflight) read the image filename from rec[0] — the first column, positionally. But the ingestor reads it BY NAME: record.get("filename") over the header-keyed record (file_transfer.py / record_processor.py), order-independent, and every template's labels.csv names the column "filename". So a `label,filename` header (filename present but not first — a layout the cluster ingests cleanly) made the CLI read the LABEL value ("cat") as the filename, miss every row, and reject with exit 3 ("N labels.csv row(s) reference images that aren't in images/") — a false reject that also misleadingly listed label values as missing images. Violated the "never stricter than the ingestor" preflight contract. Fix: resolve the filename column by name — exact "filename" first, then case-insensitive + whitespace-trimmed (the ingestor's _match_column rule) — and read that column's index. Falls back to column 0 when there's no filename-ish column (a malformed layout the ingestor fails on regardless; not this check's job to diagnose). Short/ragged rows without that cell are skipped. Tests: the existing test's unrealistic `image_id,label` fixture corrected to the real `filename,label`; new TestCrossCheckLabels_FilenameColumnNotFirst pins that `label,Filename` (not first, mixed case) resolves correctly and does NOT read labels as filenames (fails against the old rec[0]); TestFilenameColIndex covers first/not-first/case/whitespace/fallback. Full suite green; gofmt -s / errcheck / ineffassign / misspell clean. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(preflight): resolve the image column as filename else data_id, like the ingestor Addresses @saadqbal's #207 review. The ingestor resolves the image file key as `filename` else `data_id` (image_paths / image_loader), position-independent for both — so a valid `label,data_id` CSV (no filename column) was falling back to index 0 (the label column) and false-rejecting (exit 3) a dataset the cluster ingests cleanly. imageFileColIndex (renamed from filenameColIndex) now matches filename-else-data_id with the same exact-then-case-insensitive-trimmed rule; filename wins when both are present; it falls back to 0 only when NEITHER exists (a labels.csv the ingestor rejects at validate_data anyway). Added a label,data_id CrossCheckLabels regression test + data_id cases to the index test. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 (1M context) --- internal/push/preflight.go | 45 +++++++++++++--- internal/push/preflight_test.go | 93 ++++++++++++++++++++++++++++++++- 2 files changed, 131 insertions(+), 7 deletions(-) diff --git a/internal/push/preflight.go b/internal/push/preflight.go index bbdb617f..f890b640 100644 --- a/internal/push/preflight.go +++ b/internal/push/preflight.go @@ -294,8 +294,11 @@ func ValidateImages(images []string, expectedW, expectedH, minW, minH int) error // direction for image_classification) — the caller may surface them as a // note. // -// filenameColumn is the CSV's first column (the ingestor reads filenames -// positionally from the id column of labels.csv). +// The image filename is read from the column NAMED "filename", not positionally: +// the ingestor does record.get("filename") over the header-keyed record +// (file_transfer.py / record_processor.py), so a `label,filename` header (filename +// not first) resolves to that column — reading rec[0] would treat the LABEL value +// as the filename and false-reject a layout the cluster ingests cleanly. func CrossCheckLabels(csvPath string, images []string, extension string) (missing []string, orphans []string, err error) { r, closer, err := openCSVReader(csvPath) if err != nil { @@ -309,12 +312,14 @@ func CrossCheckLabels(csvPath string, images []string, extension string) (missin } referenced := make(map[string]bool) - if _, err := r.Read(); err != nil { // header + header, err := r.Read() + if err != nil { if errors.Is(err, io.EOF) { return nil, nil, nil // emptiness is CheckHasDataRows' diagnostic } return nil, nil, fmt.Errorf("reading %s: %w", filepath.Base(csvPath), err) } + fnIdx := imageFileColIndex(header) for { rec, err := r.Read() if errors.Is(err, io.EOF) { @@ -323,10 +328,10 @@ func CrossCheckLabels(csvPath string, images []string, extension string) (missin if err != nil { return nil, nil, fmt.Errorf("reading %s: %w", filepath.Base(csvPath), err) } - if len(rec) == 0 { - continue + if fnIdx >= len(rec) { + continue // short/ragged row — no filename cell to check } - name := strings.TrimSpace(rec[0]) + name := strings.TrimSpace(rec[fnIdx]) if name == "" { continue } @@ -352,6 +357,34 @@ func CrossCheckLabels(csvPath string, images []string, extension string) (missin return missing, orphans, nil } +// imageFileColIndex returns the header index of the column the ingestor reads each +// image's file key from: "filename" if present, else "data_id" — the ingestor's own +// precedence (image_paths.prepare_classification_pytorch_image_df / image_loader), +// position-independent for both. A label,data_id CSV (no filename column) is ingested +// cleanly by the cluster, so matching only "filename" and falling back to index 0 +// would read the label column as filenames and false-reject it (exit 3). +// +// Each name is matched exactly first, then case-insensitively with surrounding +// whitespace stripped (the ingestor's _match_column rule). "filename" wins over +// "data_id" when both resolve. Falls back to 0 only when NEITHER column exists — a +// labels.csv the ingestor rejects at validate_data regardless, not this check's job +// to diagnose. +func imageFileColIndex(header []string) int { + for _, want := range []string{"filename", "data_id"} { + for i, h := range header { + if strings.TrimSpace(h) == want { + return i + } + } + for i, h := range header { + if strings.EqualFold(strings.TrimSpace(h), want) { + return i + } + } + } + return 0 +} + // CheckAnnotationPairing previews the ingestor's FilePairingValidator // (file_pairing_validator.py) for object_detection: every image must have // an annotation with the same filename stem and vice versa — a mismatch in diff --git a/internal/push/preflight_test.go b/internal/push/preflight_test.go index b3bc11c7..fbeb4010 100644 --- a/internal/push/preflight_test.go +++ b/internal/push/preflight_test.go @@ -212,8 +212,10 @@ func TestCrossCheckLabels(t *testing.T) { // One row exact, one extensionless (the ingestor appends the dataset // extension — the check must mirror that), one missing. csvPath := filepath.Join(dir, "labels.csv") + // Realistic image_classification labels.csv: the ingestor reads the image name + // from the column NAMED "filename" (record.get("filename")). if err := os.WriteFile(csvPath, - []byte("image_id,label\na.jpg,cat\nb,dog\nghost.jpg,cat\n"), 0o644); err != nil { + []byte("filename,label\na.jpg,cat\nb,dog\nghost.jpg,cat\n"), 0o644); err != nil { t.Fatal(err) } images := []string{filepath.Join(imgs, "a.jpg"), filepath.Join(imgs, "b.jpg"), filepath.Join(imgs, "extra.jpg")} @@ -229,6 +231,95 @@ func TestCrossCheckLabels(t *testing.T) { } } +// The image filename is resolved by the "filename" COLUMN NAME, not positionally — +// so a `label,filename` header (filename not first, a layout the ingestor accepts +// via record.get("filename")) must not false-reject. Reading rec[0] would treat the +// label value ("cat"/"dog") as the filename and flag every row missing (exit 3). +func TestCrossCheckLabels_FilenameColumnNotFirst(t *testing.T) { + dir := t.TempDir() + imgs := filepath.Join(dir, "images") + if err := os.MkdirAll(imgs, 0o755); err != nil { + t.Fatal(err) + } + for _, n := range []string{"a.jpg", "b.jpg", "extra.jpg"} { + if err := os.WriteFile(filepath.Join(imgs, n), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + csvPath := filepath.Join(dir, "labels.csv") + // filename is the SECOND column, and mixed-case to exercise the ci match. + if err := os.WriteFile(csvPath, + []byte("label,Filename\ncat,a.jpg\ndog,b\ncat,ghost.jpg\n"), 0o644); err != nil { + t.Fatal(err) + } + images := []string{filepath.Join(imgs, "a.jpg"), filepath.Join(imgs, "b.jpg"), filepath.Join(imgs, "extra.jpg")} + missing, orphans, err := CrossCheckLabels(csvPath, images, ".jpg") + if err != nil { + t.Fatal(err) + } + if len(missing) != 1 || missing[0] != "ghost.jpg" { + t.Errorf("missing = %v, want [ghost.jpg] — the label values must NOT be read as filenames", missing) + } + if len(orphans) != 1 || orphans[0] != "extra.jpg" { + t.Errorf("orphans = %v, want [extra.jpg]", orphans) + } +} + +// A `label,data_id` header (no filename column) is ingested cleanly by the cluster — +// the ingestor resolves the image column as filename ELSE data_id. CrossCheckLabels +// must resolve data_id, not fall back to index 0 (the label column), which would read +// "cat"/"dog" as filenames and false-reject every row (exit 3). Regression for Asad's +// #207 review. +func TestCrossCheckLabels_DataIDColumn(t *testing.T) { + dir := t.TempDir() + imgs := filepath.Join(dir, "images") + if err := os.MkdirAll(imgs, 0o755); err != nil { + t.Fatal(err) + } + for _, n := range []string{"a.jpg", "b.jpg", "extra.jpg"} { + if err := os.WriteFile(filepath.Join(imgs, n), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + csvPath := filepath.Join(dir, "labels.csv") + // data_id instead of filename, and not first — a layout the ingestor accepts. + if err := os.WriteFile(csvPath, + []byte("label,data_id\ncat,a.jpg\ndog,b\ncat,ghost.jpg\n"), 0o644); err != nil { + t.Fatal(err) + } + images := []string{filepath.Join(imgs, "a.jpg"), filepath.Join(imgs, "b.jpg"), filepath.Join(imgs, "extra.jpg")} + missing, orphans, err := CrossCheckLabels(csvPath, images, ".jpg") + if err != nil { + t.Fatal(err) + } + if len(missing) != 1 || missing[0] != "ghost.jpg" { + t.Errorf("missing = %v, want [ghost.jpg] — data_id must resolve; label values must NOT be read as filenames", missing) + } + if len(orphans) != 1 || orphans[0] != "extra.jpg" { + t.Errorf("orphans = %v, want [extra.jpg]", orphans) + } +} + +func TestImageFileColIndex(t *testing.T) { + cases := []struct { + header []string + want int + }{ + {[]string{"filename", "label"}, 0}, // filename by name, first + {[]string{"label", "filename"}, 1}, // filename by name, not first (the original bug) + {[]string{"label", " Filename "}, 1}, // case + whitespace insensitive + {[]string{"label", "data_id"}, 1}, // no filename → data_id (the ingestor's fallback) + {[]string{"label", "Data_ID"}, 1}, // data_id, case-insensitive + {[]string{"data_id", "filename", "label"}, 1}, // filename wins over data_id when both present + {[]string{"image_id", "label"}, 0}, // neither → fallback to 0 + } + for _, c := range cases { + if got := imageFileColIndex(c.header); got != c.want { + t.Errorf("imageFileColIndex(%v) = %d, want %d", c.header, got, c.want) + } + } +} + func TestCheckAnnotationPairing(t *testing.T) { imgs := []string{"images/a.jpg", "images/b.jpg"} anns := []string{"annotations/a.xml", "annotations/c.xml"} From ebe36b9d8f5a404555bc7dc4d1e9cdb93a6a411a Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:54:26 +0200 Subject: [PATCH 14/22] feat(push): add time_series_classification category (backend#1054 WS2) (#216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the sequence-grouped time-series task end to end on the CLI side: - category registry: time_series_classification, FamilyTabular, CLISupported, IsClassification (mirrors the ingestor registry's is_classification=True). Not RegressionClass: real class labels use the plain string label form, no label.policy. --task help and the interactive picker derive from the registry, so both pick it up without further edits. No spec.go / flag / prompt changes — the sequence_id / timestamp column names are fixed (Decision-2). - re-vendor ingest.v1.json + layout.v1.json from the WS1 branch (data-ingestors#359) and bump scripts/.data-ingestors-ref to its head SHA so sync-schema.sh --check stays green. The registry<->schema parity tests pin the new enum both ways. - layout contract: parse the new grouping trait (Decision-4) and expose GroupingFor; grouped preflight behaviour gates on the trait, never on a category id. - preflight: extend the label-diversity gate from a hardcoded tabular_classification to IsClassification, and add the cheap local sequence checks — CheckSequenceSchemaColumns (fixed sequence_id / timestamp columns declared in the schema, previewing the ingest.v1 conditional) and CheckSequenceRows (no null/empty sequence ids, previewing SequenceGroupValidator; also yields the sequence count, echoed as a note since the platform counts sequences, not rows — Decision-3). - parity harness: 6 new tsc-* cases with goldens generated from the REAL WS1 validators (existing goldens unchanged). tsc-label-flip and tsc-unsorted-timestamp document the two deliberate divergences (no local whole-group label-constancy / per-group order preview yet). - README: category listing updated (15 of 16 supported). Part of backend#1054 (WS2, backend#1057). Requires data-ingestors#359 in the same release window (T16) — the vendored schema comes from that branch. Co-authored-by: Claude Opus 4.8 --- README.md | 2 +- internal/push/category.go | 11 ++ internal/push/category_registry_test.go | 12 +- internal/push/layout_contract.go | 28 ++++ internal/push/layout_contract_test.go | 38 +++++ internal/push/preflight.go | 125 +++++++++++++++- internal/push/preflight_test.go | 138 ++++++++++++++++++ internal/push/spec_test.go | 43 ++++++ internal/push/testdata/parity/cases.json | 95 ++++++++++++ .../parity/cases/tsc-label-flip/data.csv | 5 + .../parity/cases/tsc-label-uniform/data.csv | 5 + .../cases/tsc-missing-sequence-col/data.csv | 4 + .../cases/tsc-null-sequence-id/data.csv | 5 + .../testdata/parity/cases/tsc-ok/data.csv | 8 + .../cases/tsc-unsorted-timestamp/data.csv | 6 + internal/push/testdata/parity/goldens.json | 36 +++++ internal/schema/ingest.v1.json | 18 ++- internal/schema/layout.v1.json | 33 ++++- scripts/.data-ingestors-ref | 10 +- 19 files changed, 612 insertions(+), 10 deletions(-) create mode 100644 internal/push/testdata/parity/cases/tsc-label-flip/data.csv create mode 100644 internal/push/testdata/parity/cases/tsc-label-uniform/data.csv create mode 100644 internal/push/testdata/parity/cases/tsc-missing-sequence-col/data.csv create mode 100644 internal/push/testdata/parity/cases/tsc-null-sequence-id/data.csv create mode 100644 internal/push/testdata/parity/cases/tsc-ok/data.csv create mode 100644 internal/push/testdata/parity/cases/tsc-unsorted-timestamp/data.csv diff --git a/README.md b/README.md index 57d0ac8f..78b73aa1 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The customer-facing CLI for the tracebloc declarative ingestion path. Wraps the **v0.3.0 is released** — the latest stable [release](https://github.com/tracebloc/cli/releases/latest), cut from `develop`. It builds on v0.2.0's guided `data ingest` and `dataset rm` with a new `dataset list` command plus home-screen / output polish (clearer copy, guided-first framing). The binary implements `version`, `completion`, `data validate`, `cluster info`, and the full `data ingest` / `dataset list` / `dataset rm` flow — local schema validation, cluster discovery, data staging, submission, and Job watching, end to end. -`data ingest` covers **9 of 10 task categories**: `image_classification`, `object_detection`, `keypoint_detection`, `text_classification`, `masked_language_modeling`, `tabular_classification`, `tabular_regression`, `time_series_forecasting`, and `time_to_event_prediction`. `semantic_segmentation` is pending mask-sidecar support upstream ([data-ingestors#136](https://github.com/tracebloc/data-ingestors/issues/136)). +`data ingest` covers **15 of 16 task categories**: `image_classification`, `object_detection`, `keypoint_detection`, `text_classification`, `token_classification`, `sentence_pair_classification`, `masked_language_modeling`, `causal_language_modeling`, `seq2seq`, `embeddings`, `tabular_classification`, `tabular_regression`, `time_series_forecasting`, `time_series_classification`, and `time_to_event_prediction`. `semantic_segmentation` is pending mask-sidecar support upstream ([data-ingestors#136](https://github.com/tracebloc/data-ingestors/issues/136)). The release pipeline ships [`v0.3.0`](https://github.com/tracebloc/cli/releases/latest) as **cosign-signed, multi-arch binaries** — Linux (`amd64`, `arm64`, `386`, `arm`), macOS (`amd64`, `arm64`), and Windows (`amd64`, `arm64`) — each with `SHA256SUMS` and the install scripts. Install via [Customer experience](#customer-experience) or [build from source](#building-from-source). (A Homebrew tap and the `install.tracebloc.io` vanity URL are later follow-ups; the GitHub release URL serves installs today.) diff --git a/internal/push/category.go b/internal/push/category.go index a01d5dd3..08cbad34 100644 --- a/internal/push/category.go +++ b/internal/push/category.go @@ -109,6 +109,17 @@ var categoryRegistry = []CategorySpec{ Blurb: "predict a number from table columns"}, {ID: "time_series_forecasting", Family: FamilyTabular, Label: "Time-series forecasting", RegressionClass: true, CLISupported: true, Blurb: "predict future values from past ones"}, + // time_series_classification is the sequence-GROUPED time-series task + // (backend#1054): the CSV carries fixed sequence_id / timestamp columns + // (Decision-2), each sequence_id groups the timestep rows of ONE sequence, + // and the label is constant within it — one class per whole sequence, not + // per row. NOT RegressionClass (real class labels → plain string label + // form, no label.policy); IsClassification mirrors the ingestor registry's + // is_classification=True, so the label-diversity preflight gates it. The + // per-sequence grouping facts live in the vendored layout contract's + // grouping trait (Decision-4), read via GroupingFor — not hardcoded here. + {ID: "time_series_classification", Family: FamilyTabular, Label: "Time-series classification", CLISupported: true, IsClassification: true, + Blurb: "predict a class for each whole sequence"}, {ID: "time_to_event_prediction", Family: FamilyTabular, Label: "Time-to-event prediction", Gloss: "Survival analysis", RegressionClass: true, CLISupported: true, Blurb: "predict how long until an event happens"}, {ID: "causal_language_modeling", Family: FamilyText, Label: "Causal language modeling", CLISupported: true, SelfSupervised: true, diff --git a/internal/push/category_registry_test.go b/internal/push/category_registry_test.go index 7e7c0f43..91428bd0 100644 --- a/internal/push/category_registry_test.go +++ b/internal/push/category_registry_test.go @@ -20,7 +20,8 @@ func TestRegistryKnownCategories(t *testing.T) { "masked_language_modeling", "causal_language_modeling", "seq2seq", "sentence_pair_classification", "embeddings", "tabular_classification", "tabular_regression", - "time_series_forecasting", "time_to_event_prediction", + "time_series_forecasting", "time_series_classification", + "time_to_event_prediction", } if got := AllCategoryIDs(); !equalSet(got, want) { t.Fatalf("AllCategoryIDs() = %v, want set %v", got, want) @@ -38,10 +39,11 @@ func TestRegistryKnownCategories(t *testing.T) { func TestSupportedCategories(t *testing.T) { got := SupportedCategoryIDs() // RFC-0002 phase 4 wired the 5 text tasks (token/sentence-pair - // classification, causal LM, seq2seq, embeddings), so 14 of the 15 - // categories are pushable; only semantic_segmentation remains pending. - if len(got) != 14 { - t.Fatalf("SupportedCategoryIDs() len = %d, want 14: %v", len(got), got) + // classification, causal LM, seq2seq, embeddings) and backend#1054 WS2 + // added time_series_classification, so 15 of the 16 categories are + // pushable; only semantic_segmentation remains pending. + if len(got) != 15 { + t.Fatalf("SupportedCategoryIDs() len = %d, want 15: %v", len(got), got) } for _, id := range got { if !IsCLISupported(id) { diff --git a/internal/push/layout_contract.go b/internal/push/layout_contract.go index ca28af8a..9bd68c0b 100644 --- a/internal/push/layout_contract.go +++ b/internal/push/layout_contract.go @@ -39,6 +39,21 @@ type TaskLayout struct { PrimarySubdir *string `json:"primary_subdir"` // images | texts | sequences | null Sidecars []SidecarSpec `json:"sidecars"` RecordFormat *RecordFormat `json:"record_format"` // structured-text tasks only + Grouping *GroupingSpec `json:"grouping"` // sequence-grouped tasks only (time_series_classification) +} + +// GroupingSpec is the sequence-grouping trait a grouped task declares +// (backend#1054 Decision-4: grouping is a ModalitySpec TRAIT the contract +// carries, never a category if/else in consuming code). GroupColumn names the +// column whose value groups the timestep rows of one sequence; TimeColumn +// orders the rows WITHIN each group; CountUnit is the SAMPLE UNIT the platform +// counts in ("sequences" — labels payloads, data_per_class, metrics are all +// per-sequence, not per-row). Mirrors the ingestor registry's +// ModalitySpec.grouping (data-ingestors modalities/spec.py). +type GroupingSpec struct { + GroupColumn string `json:"group_column"` // sequence_id (fixed, Decision-2) + TimeColumn string `json:"time_column"` // timestamp (fixed, Decision-2) + CountUnit string `json:"count_unit"` // "sequences" } // ManifestLayout describes the task's manifest CSV. @@ -92,6 +107,19 @@ func LayoutFor(category string) (TaskLayout, bool) { return t, ok } +// GroupingFor returns the sequence-grouping trait for a category and whether +// it declares one (today only time_series_classification). Ungrouped tasks +// return false. Consumers gate per-sequence behaviour on THIS trait, never on +// a category id (Decision-4) — a future grouped task is handled the moment +// the vendored contract declares it, with zero CLI edits. +func GroupingFor(category string) (GroupingSpec, bool) { + t, ok := layoutContract.Tasks[category] + if !ok || t.Grouping == nil { + return GroupingSpec{}, false + } + return *t.Grouping, true +} + // RecordFormatFor returns the record format for a text category and whether it // declares one. Tasks without a structured .txt shape (text_classification, // token_classification, MLM) return false. diff --git a/internal/push/layout_contract_test.go b/internal/push/layout_contract_test.go index 0a5809ba..ccc7a755 100644 --- a/internal/push/layout_contract_test.go +++ b/internal/push/layout_contract_test.go @@ -171,3 +171,41 @@ func TestValidateTextRecord(t *testing.T) { t.Errorf("empty file should be tolerated by the structural check: %v", err) } } + +// TestGroupingForMirrorsContract pins the sequence-grouping trait +// (backend#1054 Decision-4) against the vendored contract: +// time_series_classification — and ONLY it, today — declares grouping, with +// the platform's fixed column names (Decision-2) and the sequence count unit +// (Decision-3). Every other category must stay ungrouped, so the grouped +// preflight path can't accidentally fire for them. +func TestGroupingForMirrorsContract(t *testing.T) { + g, ok := GroupingFor("time_series_classification") + if !ok { + t.Fatal("time_series_classification must declare a grouping trait in the vendored contract") + } + if g.GroupColumn != "sequence_id" || g.TimeColumn != "timestamp" || g.CountUnit != "sequences" { + t.Errorf("grouping = %+v, want the fixed {sequence_id, timestamp, sequences} contract", g) + } + + for _, c := range categoryRegistry { + if c.ID == "time_series_classification" { + continue + } + if _, grouped := GroupingFor(c.ID); grouped { + t.Errorf("%s: unexpectedly declares a grouping trait — only the sequence-grouped "+ + "time-series task is grouped today; a new grouped task needs a conscious "+ + "preflight/staging review, not a silent contract edit", c.ID) + } + } + + // A grouped task is tabular (single data CSV) and a classification task + // — the facts the grouped preflight path relies on. + if !IsTabular("time_series_classification") || !IsClassification("time_series_classification") { + t.Error("time_series_classification must be tabular-family and is_classification") + } + + // Unknown category: no grouping, no panic. + if _, grouped := GroupingFor("nope"); grouped { + t.Error("unknown category must report no grouping") + } +} diff --git a/internal/push/preflight.go b/internal/push/preflight.go index f890b640..84649d81 100644 --- a/internal/push/preflight.go +++ b/internal/push/preflight.go @@ -678,6 +678,101 @@ func CheckSchemaColumns(header []string, schema map[string]string, csvName strin csvName, strings.Join(missing, ", ")) } +// CheckSequenceSchemaColumns previews the ingest.v1 schema's sequence-grouped +// conditional (the time_series_classification if/then) plus the presence +// probes of SequenceGroupValidator / PerGroupTimeOrderedValidator: a grouped +// task's schema must declare BOTH fixed sequence columns — the group key +// (sequence_id) and the time column (timestamp). The names are FIXED by the +// platform (backend#1054 Decision-2); there is no flag to rename them, so the +// fix is always renaming the CSV columns (or extending an explicit --schema). +// Compared as exact schema-map keys, matching the JSON-schema `required` +// semantics — the vendored-schema validation would reject the same YAML, this +// check just fails earlier with a friendlier message. +func CheckSequenceSchemaColumns(schema map[string]string, g GroupingSpec) error { + var missing []string + for _, col := range []string{g.GroupColumn, g.TimeColumn} { + if _, ok := schema[col]; !ok { + missing = append(missing, col) + } + } + if len(missing) == 0 { + return nil + } + return fmt.Errorf( + "this task's data is sequence-grouped: the schema must declare %q (groups the timestep "+ + "rows of one sequence — e.g. a patient/device/session id) and %q (orders the rows "+ + "within each sequence). Missing: %s. The column names are fixed by the platform — "+ + "rename your CSV columns to match and re-run.", + g.GroupColumn, g.TimeColumn, strings.Join(missing, ", ")) +} + +// CheckSequenceRows previews the SequenceGroupValidator's null-id rule +// (sequence_group_validator.py): every timestep row must carry a non-empty +// sequence id — a row whose group key is null/empty belongs to NO sequence, +// so it can't contribute to any per-sequence sample and the in-cluster +// rejection otherwise lands after the full upload. Together with +// CheckHasDataRows this guarantees every sequence has >= 1 real row and at +// least one sequence exists at all. +// +// NA sentinels count as null: the ingestor loads the column with pandas, +// whose NA parsing turns "NA"/"null"/… into NaN before the validator's +// isna() probe — mirrored here via naSentinels (the ingestor's +// coercion.NA_SENTINELS). The column is resolved with the shared +// case-/whitespace-insensitive rule (#340). An absent column benign-skips +// (returns 0, nil): that is CheckSequenceSchemaColumns' / +// CheckSchemaColumns' diagnostic, not this one's. +// +// sequences is the count of distinct non-null ids — the dataset's SAMPLE +// count, since the platform counts sequence-grouped data in sequences, not +// rows (backend#1054 Decision-3); the caller echoes it as a note. +func CheckSequenceRows(csvPath, groupColumn string) (sequences int, err error) { + r, closer, err := openCSVReader(csvPath) + if err != nil { + return 0, nil // unreadable file is another check's diagnostic + } + defer func() { _ = closer.Close() }() + header, err := r.Read() + if err != nil { + return 0, nil + } + col := matchColumnIndex(header, groupColumn) + if col == -1 { + return 0, nil // benign skip — the schema checks own this diagnostic + } + distinct := map[string]bool{} + nullCount, rowNum, firstNullRow := 0, 0, 0 + for { + rec, err := r.Read() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + continue + } + rowNum++ + v := "" + if len(rec) > col { + v = strings.TrimSpace(rec[col]) + } + if _, isNA := naSentinels[v]; isNA { + nullCount++ + if firstNullRow == 0 { + firstNullRow = rowNum + } + continue + } + distinct[v] = true + } + if nullCount > 0 { + return len(distinct), fmt.Errorf( + "the sequence column %q has %d empty/null value(s) (first at data row %d). Every "+ + "timestep row must carry the id of the sequence it belongs to — the cluster rejects "+ + "this after the upload; fill in the ids and re-run.", + groupColumn, nullCount, firstNullRow) + } + return len(distinct), nil +} + // PreflightProblem is a preflight rejection. BadFlag marks problems whose // fix is a flag value (the CLI maps those to exit 2); everything else is a // data problem (exit 3). @@ -730,7 +825,35 @@ func PreflightDataset(spec SpecArgs, layout *LocalLayout) (notes []string, probl if err := CheckLabelColumn(header, spec.LabelColumn, "the data CSV"); err != nil { return nil, &PreflightProblem{Err: err, BadFlag: true} } - if spec.Category == "tabular_classification" { + // Sequence-grouped tasks (time_series_classification), gated on the + // vendored contract's grouping TRAIT — never the category id + // (backend#1054 Decision-4). Previews SequenceGroupValidator + + // the ingest.v1 sequence-column conditional: the fixed sequence_id / + // timestamp columns must be in the schema, and every timestep row + // must carry a sequence id. Runs before the label checks, mirroring + // the ingestor's factory order (SequenceGroupValidator first). + if g, grouped := GroupingFor(spec.Category); grouped { + if err := CheckSequenceSchemaColumns(spec.Schema, g); err != nil { + return nil, dataProblem(err) + } + seqs, err := CheckSequenceRows(layout.LabelsCSV, g.GroupColumn) + if err != nil { + return nil, dataProblem(err) + } + if seqs > 0 { + // The platform counts this dataset in sequences, not rows + // (Decision-3) — echo the sample count the customer will see. + notes = append(notes, fmt.Sprintf( + "Note: %d sequence(s) grouped by %q — the platform counts this dataset "+ + "in sequences, not rows", seqs, g.GroupColumn)) + } + } + // Label diversity for every tabular classification task — gated on + // the registry's IsClassification (the ingestor's is_classification + // wiring: tabular_classification + time_series_classification), not + // a hardcoded id, so a future classification task can't silently + // skip the preview. + if IsClassification(spec.Category) { // The label is a schema-typed column: the ingestor drops NA // sentinels for it, and collapses numeric-looking values ONLY // for numeric types — a VARCHAR label is pinned to dtype=str, diff --git a/internal/push/preflight_test.go b/internal/push/preflight_test.go index fbeb4010..9fb5259a 100644 --- a/internal/push/preflight_test.go +++ b/internal/push/preflight_test.go @@ -439,3 +439,141 @@ func TestPreflightDataset_TextLabelParity(t *testing.T) { "(BIO labels aren't class labels; the ingestor runs BIOLabelValidator, not LabelDiversity): %v", problem.Err) } } + +func TestCheckSequenceSchemaColumns(t *testing.T) { + // Previews the ingest.v1 sequence-grouped conditional (backend#1054 + // Decision-2): the schema must declare BOTH fixed sequence columns. + g := GroupingSpec{GroupColumn: "sequence_id", TimeColumn: "timestamp", CountUnit: "sequences"} + + ok := map[string]string{"sequence_id": "VARCHAR(64)", "timestamp": "INT", "hr": "FLOAT", "label": "VARCHAR(16)"} + if err := CheckSequenceSchemaColumns(ok, g); err != nil { + t.Errorf("schema with both sequence columns rejected: %v", err) + } + + missingBoth := map[string]string{"hr": "FLOAT", "label": "VARCHAR(16)"} + err := CheckSequenceSchemaColumns(missingBoth, g) + if err == nil { + t.Fatal("schema without sequence_id/timestamp must be rejected") + } + for _, want := range []string{"sequence_id", "timestamp", "fixed by the platform"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error should mention %q, got: %v", want, err) + } + } + + // The JSON-schema `required` is exact on keys — a case variant must not + // satisfy it, or the CLI would accept a YAML the schema validation (and + // the cluster) rejects. + caseVariant := map[string]string{"Sequence_ID": "VARCHAR(64)", "timestamp": "INT"} + if err := CheckSequenceSchemaColumns(caseVariant, g); err == nil { + t.Error("a case-variant sequence_id key must not satisfy the schema conditional") + } +} + +func TestCheckSequenceRows(t *testing.T) { + // Previews SequenceGroupValidator's null-id rule: every timestep row + // must carry a sequence id; NA sentinels count as null (pandas parity). + g := "sequence_id" + + good := writeTmp(t, "good.csv", []byte("sequence_id,timestamp,hr,label\np1,1,80,sepsis\np1,2,82,sepsis\np2,1,70,healthy\n")) + seqs, err := CheckSequenceRows(good, g) + if err != nil { + t.Fatalf("clean grouped CSV rejected: %v", err) + } + if seqs != 2 { + t.Errorf("sequences = %d, want 2 (the platform counts sequences, not rows)", seqs) + } + + // Empty and NA-sentinel ids are both null (the ingestor loads with + // pandas, whose NA parsing fires before the isna() probe). + nulls := writeTmp(t, "nulls.csv", []byte("sequence_id,timestamp,hr,label\np1,1,80,a\n,2,82,a\nNA,3,84,b\n")) + if _, err := CheckSequenceRows(nulls, g); err == nil { + t.Fatal("rows with empty/NA sequence ids must be rejected") + } else if !strings.Contains(err.Error(), "2 empty/null value(s)") { + t.Errorf("error should count both null forms, got: %v", err) + } + + // Header resolution follows the shared case-/whitespace-insensitive + // rule (#340) — a " Sequence_ID " header still resolves. + loose := writeTmp(t, "loose.csv", []byte(" Sequence_ID ,timestamp,label\np1,1,a\np2,1,b\n")) + if seqs, err := CheckSequenceRows(loose, g); err != nil || seqs != 2 { + t.Errorf("case/whitespace-variant header must resolve (ingestor rule): seqs=%d err=%v", seqs, err) + } + + // Absent column benign-skips — CheckSequenceSchemaColumns owns that + // diagnostic, exactly like the diversity check's benign skip. + noCol := writeTmp(t, "nocol.csv", []byte("id,timestamp,label\np1,1,a\n")) + if _, err := CheckSequenceRows(noCol, g); err != nil { + t.Errorf("missing column must benign-skip: %v", err) + } +} + +// TestPreflightDataset_SequenceGrouped locks the dispatch-level wiring for the +// sequence-grouped tabular task (time_series_classification, backend#1054): +// the grouping checks fire off the vendored contract's grouping TRAIT +// (Decision-4), the diversity gate fires off IsClassification (not a +// hardcoded id), and the ungrouped time-series sibling is untouched. +func TestPreflightDataset_SequenceGrouped(t *testing.T) { + writeLayout := func(t *testing.T, content string) *LocalLayout { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "data.csv") + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return &LocalLayout{Root: dir, LabelsCSV: p} + } + schema := map[string]string{ + "sequence_id": "VARCHAR(64)", "timestamp": "INT", + "hr": "FLOAT", "label": "VARCHAR(16)", + } + spec := func(s map[string]string) SpecArgs { + return SpecArgs{Category: "time_series_classification", LabelColumn: "label", Schema: s} + } + + // Valid two-sequence, two-class dataset: accepted, and the advisory + // note surfaces the SEQUENCE count (Decision-3's sample unit). + good := "sequence_id,timestamp,hr,label\np1,1,80,sepsis\np1,2,82,sepsis\np2,1,70,healthy\n" + notes, problem := PreflightDataset(spec(schema), writeLayout(t, good)) + if problem != nil { + t.Fatalf("valid grouped dataset rejected: %v", problem.Err) + } + foundNote := false + for _, n := range notes { + if strings.Contains(n, "2 sequence(s)") { + foundNote = true + } + } + if !foundNote { + t.Errorf("expected a sequence-count note, got %v", notes) + } + + // Schema missing the fixed sequence columns → rejected before upload. + bare := map[string]string{"hr": "FLOAT", "label": "VARCHAR(16)"} + bareCSV := "hr,label\n80,sepsis\n70,healthy\n" + if _, problem := PreflightDataset(spec(bare), writeLayout(t, bareCSV)); problem == nil { + t.Error("schema without sequence_id/timestamp should be rejected") + } + + // A null sequence id → rejected (SequenceGroupValidator preview). + nullID := "sequence_id,timestamp,hr,label\np1,1,80,sepsis\n,2,82,sepsis\np2,1,70,healthy\n" + if _, problem := PreflightDataset(spec(schema), writeLayout(t, nullID)); problem == nil { + t.Error("a row with an empty sequence_id should be rejected") + } + + // Single-class labels → rejected via IsClassification (the diversity + // gate must cover TSC, not just tabular_classification). + oneClass := "sequence_id,timestamp,hr,label\np1,1,80,sepsis\np2,1,70,sepsis\n" + if _, problem := PreflightDataset(spec(schema), writeLayout(t, oneClass)); problem == nil { + t.Error("a single-class grouped dataset should be rejected (LabelDiversityValidator preview)") + } + + // The ungrouped time-series sibling must NOT gain the grouping checks: + // forecasting has no grouping trait and no diversity gate. + tsf := SpecArgs{Category: "time_series_forecasting", LabelColumn: "label", + Schema: map[string]string{"timestamp": "INT", "hr": "FLOAT", "label": "FLOAT"}} + tsfCSV := "timestamp,hr,label\n1,80,0.1\n2,82,0.1\n" + if _, problem := PreflightDataset(tsf, writeLayout(t, tsfCSV)); problem != nil { + t.Errorf("time_series_forecasting must stay ungrouped and diversity-free: %v", problem.Err) + } +} diff --git a/internal/push/spec_test.go b/internal/push/spec_test.go index 65e2becb..bf2267e4 100644 --- a/internal/push/spec_test.go +++ b/internal/push/spec_test.go @@ -341,6 +341,49 @@ func TestBuild_Tabular_PassesSchema(t *testing.T) { LabelColumn: "DEATH_EVENT", TimeColumn: "time", Schema: map[string]string{"age": "INT", "time": "INT", "DEATH_EVENT": "INT"}, }, true) + + // time_series_classification (backend#1054): classification-class, so + // the label takes the plain STRING form (no label.policy) even though + // its time-series siblings are regression-class — and the schema must + // carry the fixed sequence columns (Decision-2), or the vendored + // ingest.v1 conditional rejects it (see the negative test below). + check("time_series_classification", SpecArgs{ + Table: "t_tsc", Category: "time_series_classification", Intent: "train", + LabelColumn: "label", + Schema: map[string]string{ + "sequence_id": "VARCHAR(64)", "timestamp": "INT", + "hr": "FLOAT", "label": "INT", + }, + }, false) +} + +// TestBuild_TSC_SchemaConditionalRequiresSequenceColumns pins that the +// VENDORED ingest.v1 schema actually enforces the sequence-grouped +// conditional (backend#1054 Decision-2): a time_series_classification spec +// whose schema map lacks sequence_id/timestamp must FAIL local validation — +// proof the WS1 schema re-sync landed, not just the enum value. +func TestBuild_TSC_SchemaConditionalRequiresSequenceColumns(t *testing.T) { + v, err := schema.NewV1Validator() + if err != nil { + t.Fatalf("NewV1Validator: %v", err) + } + spec := SpecArgs{ + Table: "t_tsc", Category: "time_series_classification", Intent: "train", + LabelColumn: "label", + Schema: map[string]string{"hr": "FLOAT", "label": "INT"}, + }.Build() + b, err := yaml.Marshal(spec) + if err != nil { + t.Fatalf("marshal: %v", err) + } + _, errs, parseErr := v.ValidateYAML(b) + if parseErr != nil { + t.Fatalf("parse: %v\n%s", parseErr, b) + } + if len(errs) == 0 { + t.Fatalf("schema without sequence_id/timestamp must fail the vendored ingest.v1 "+ + "conditional — the re-synced schema isn't enforcing Decision-2:\n%s", b) + } } // TestBuild_Tabular_RegressionDefaultsPolicyBucket: regression-class diff --git a/internal/push/testdata/parity/cases.json b/internal/push/testdata/parity/cases.json index fc61d9fc..dd5d5eca 100644 --- a/internal/push/testdata/parity/cases.json +++ b/internal/push/testdata/parity/cases.json @@ -349,6 +349,101 @@ "cli_verdict": "reject", "ingestor_verdict": "reject", "note": "same values under a FLOAT label: numeric read collapses 1/1.0 into one class \u2014 both sides reject (the counterpart pin)" + }, + { + "name": "tsc-ok", + "category": "time_series_classification", + "csv": "data.csv", + "label_column": "label", + "schema": { + "sequence_id": "VARCHAR(64)", + "timestamp": "INT", + "hr": "FLOAT", + "temp": "FLOAT", + "label": "INT" + }, + "cli_verdict": "accept", + "ingestor_verdict": "accept", + "note": "3 sequences (T=3/2/2), 2 classes, per-group monotonic INT step index \u2014 the WS1 done-contract shape (backend#1054)" + }, + { + "name": "tsc-missing-sequence-col", + "category": "time_series_classification", + "csv": "data.csv", + "label_column": "label", + "schema": { + "timestamp": "INT", + "hr": "FLOAT", + "temp": "FLOAT", + "label": "INT" + }, + "cli_verdict": "reject", + "ingestor_verdict": "reject", + "note": "no sequence_id anywhere: SequenceGroupValidator rejects in-cluster; the CLI previews the fixed-column requirement (ingest.v1 conditional, Decision-2)" + }, + { + "name": "tsc-null-sequence-id", + "category": "time_series_classification", + "csv": "data.csv", + "label_column": "label", + "schema": { + "sequence_id": "VARCHAR(64)", + "timestamp": "INT", + "hr": "FLOAT", + "temp": "FLOAT", + "label": "INT" + }, + "cli_verdict": "reject", + "ingestor_verdict": "reject", + "note": "empty + 'NA' sequence ids: unassignable timestep rows \u2014 SequenceGroupValidator's null-id rule, previewed by CheckSequenceRows (both null forms via pandas NA parsing)" + }, + { + "name": "tsc-label-uniform", + "category": "time_series_classification", + "csv": "data.csv", + "label_column": "label", + "schema": { + "sequence_id": "VARCHAR(64)", + "timestamp": "INT", + "hr": "FLOAT", + "temp": "FLOAT", + "label": "INT" + }, + "cli_verdict": "reject", + "ingestor_verdict": "reject", + "note": "single-class dataset: LabelDiversityValidator (is_classification=True composes it for TSC), previewed via the registry's IsClassification gate \u2014 not a hardcoded tabular_classification id" + }, + { + "name": "tsc-label-flip", + "category": "time_series_classification", + "csv": "data.csv", + "label_column": "label", + "schema": { + "sequence_id": "VARCHAR(64)", + "timestamp": "INT", + "hr": "FLOAT", + "temp": "FLOAT", + "label": "INT" + }, + "cli_verdict": "accept", + "ingestor_verdict": "reject", + "note": "DELIBERATE divergence (documented gap): p1 flips label mid-sequence \u2014 LabelConstantWithinGroupValidator rejects in-cluster, the CLI has no whole-group label-constancy preview yet (needs a per-group scan; candidate follow-up if burned uploads show up)" + }, + { + "name": "tsc-unsorted-timestamp", + "category": "time_series_classification", + "csv": "data.csv", + "label_column": "label", + "schema": { + "sequence_id": "VARCHAR(64)", + "timestamp": "INT", + "hr": "FLOAT", + "temp": "FLOAT", + "label": "INT" + }, + "cli_verdict": "accept", + "ingestor_verdict": "reject", + "note": "DELIBERATE divergence (documented gap): p1's step index is out of order \u2014 PerGroupTimeOrderedValidator rejects in-cluster (monotonic non-decreasing PER GROUP), the CLI has no per-group order preview yet (same follow-up as tsc-label-flip)" } ] } diff --git a/internal/push/testdata/parity/cases/tsc-label-flip/data.csv b/internal/push/testdata/parity/cases/tsc-label-flip/data.csv new file mode 100644 index 00000000..42fef8a5 --- /dev/null +++ b/internal/push/testdata/parity/cases/tsc-label-flip/data.csv @@ -0,0 +1,5 @@ +sequence_id,timestamp,hr,temp,label +p1,1,80,36.5,0 +p1,2,84,37.1,1 +p2,1,70,36.4,0 +p2,2,71,36.5,0 diff --git a/internal/push/testdata/parity/cases/tsc-label-uniform/data.csv b/internal/push/testdata/parity/cases/tsc-label-uniform/data.csv new file mode 100644 index 00000000..88c1ebb6 --- /dev/null +++ b/internal/push/testdata/parity/cases/tsc-label-uniform/data.csv @@ -0,0 +1,5 @@ +sequence_id,timestamp,hr,temp,label +p1,1,80,36.5,1 +p1,2,84,37.1,1 +p2,1,95,38.2,1 +p2,2,99,38.9,1 diff --git a/internal/push/testdata/parity/cases/tsc-missing-sequence-col/data.csv b/internal/push/testdata/parity/cases/tsc-missing-sequence-col/data.csv new file mode 100644 index 00000000..42366b3f --- /dev/null +++ b/internal/push/testdata/parity/cases/tsc-missing-sequence-col/data.csv @@ -0,0 +1,4 @@ +timestamp,hr,temp,label +1,80,36.5,1 +2,84,37.1,1 +1,70,36.4,0 diff --git a/internal/push/testdata/parity/cases/tsc-null-sequence-id/data.csv b/internal/push/testdata/parity/cases/tsc-null-sequence-id/data.csv new file mode 100644 index 00000000..2162ef17 --- /dev/null +++ b/internal/push/testdata/parity/cases/tsc-null-sequence-id/data.csv @@ -0,0 +1,5 @@ +sequence_id,timestamp,hr,temp,label +p1,1,80,36.5,1 +,2,84,37.1,1 +p2,1,70,36.4,0 +NA,2,71,36.5,0 diff --git a/internal/push/testdata/parity/cases/tsc-ok/data.csv b/internal/push/testdata/parity/cases/tsc-ok/data.csv new file mode 100644 index 00000000..f0cf702a --- /dev/null +++ b/internal/push/testdata/parity/cases/tsc-ok/data.csv @@ -0,0 +1,8 @@ +sequence_id,timestamp,hr,temp,label +p1,1,80,36.5,1 +p1,2,84,37.1,1 +p1,3,90,38.0,1 +p2,1,70,36.4,0 +p2,2,71,36.5,0 +p3,1,95,38.2,0 +p3,2,99,38.9,0 diff --git a/internal/push/testdata/parity/cases/tsc-unsorted-timestamp/data.csv b/internal/push/testdata/parity/cases/tsc-unsorted-timestamp/data.csv new file mode 100644 index 00000000..0edddf4c --- /dev/null +++ b/internal/push/testdata/parity/cases/tsc-unsorted-timestamp/data.csv @@ -0,0 +1,6 @@ +sequence_id,timestamp,hr,temp,label +p1,3,90,38.0,1 +p1,1,80,36.5,1 +p1,2,84,37.1,1 +p2,1,70,36.4,0 +p2,2,71,36.5,0 diff --git a/internal/push/testdata/parity/goldens.json b/internal/push/testdata/parity/goldens.json index 677290ce..f2ac8376 100644 --- a/internal/push/testdata/parity/goldens.json +++ b/internal/push/testdata/parity/goldens.json @@ -220,6 +220,42 @@ "row_count": 2 }, "verdict": "accept" + }, + "tsc-label-flip": { + "errors": [ + "LabelConstantWithinGroupValidator: Found 1 sequence(s) whose 'label' value changes mid-sequence (first offending sequences start at rows [0]). Time-series classification assigns ONE label per sequence: every row of a 'sequence_id' must repeat the same label value." + ], + "verdict": "reject" + }, + "tsc-label-uniform": { + "errors": [ + "LabelDiversityValidator: Classification category requires at least 2 distinct label values in column 'label' (after whitespace stripping); this dataset has 1 distinct value(s): [np.int64(1)]. Raw value counts: {1: 4}. If this is intentional (e.g. you have a continuous target), pick a regression-family category like tabular_regression or time_series_forecasting instead." + ], + "verdict": "reject" + }, + "tsc-missing-sequence-col": { + "errors": [ + "SequenceGroupValidator: Required sequence column 'sequence_id' not found in dataset. Available columns: ['timestamp', 'hr', 'temp', 'label']. Time-series classification data must carry a 'sequence_id' column grouping the timestep rows of each sequence (e.g. a patient / device / session id).", + "LabelConstantWithinGroupValidator: Required sequence column 'sequence_id' not found in dataset. Available columns: ['timestamp', 'hr', 'temp', 'label'].", + "PerGroupTimeOrderedValidator: Required sequence column 'sequence_id' not found. Available: ['timestamp', 'hr', 'temp', 'label']" + ], + "verdict": "reject" + }, + "tsc-null-sequence-id": { + "errors": [ + "SequenceGroupValidator: Sequence column 'sequence_id' contains 2 null/empty value(s) at rows [1, 3]. Every timestep row must carry the id of the sequence it belongs to." + ], + "verdict": "reject" + }, + "tsc-ok": { + "errors": [], + "verdict": "accept" + }, + "tsc-unsorted-timestamp": { + "errors": [ + "PerGroupTimeOrderedValidator: Found 1 sequence(s) with out-of-order 'timestamp' values (first offending rows [1]). Timestep rows must be sorted by 'timestamp' within each 'sequence_id' \u2014 sort each sequence's rows ascending and re-run. Interleaving different sequences is fine; ordering is only checked within a sequence." + ], + "verdict": "reject" } } } diff --git a/internal/schema/ingest.v1.json b/internal/schema/ingest.v1.json index b6f6bef7..2734d61b 100644 --- a/internal/schema/ingest.v1.json +++ b/internal/schema/ingest.v1.json @@ -36,6 +36,7 @@ "tabular_classification", "tabular_regression", "time_series_forecasting", + "time_series_classification", "time_to_event_prediction", "masked_language_modeling", "causal_language_modeling", @@ -388,6 +389,7 @@ "tabular_classification", "tabular_regression", "time_series_forecasting", + "time_series_classification", "time_to_event_prediction" ] } @@ -396,6 +398,19 @@ }, "then": { "required": ["schema"] } }, + { + "description": "time_series_classification requires the fixed sequence columns: `schema` must declare both `sequence_id` (VARCHAR — groups the timestep rows of one sequence, e.g. a patient/device/session id) and `timestamp` (SQL TIMESTAMP, or a numeric step index like INT — orders the rows WITHIN each sequence). The column names are fixed by the platform; rename your columns to `sequence_id` / `timestamp` before ingest.", + "if": { + "properties": { "category": { "const": "time_series_classification" } }, + "required": ["category"] + }, + "then": { + "properties": { + "schema": { "required": ["sequence_id", "timestamp"] } + }, + "required": ["schema"] + } + }, { "description": "Regression-class tasks require an explicit label.policy decision (must be the object form).", "if": { @@ -441,7 +456,8 @@ "text_classification", "token_classification", "sentence_pair_classification", - "tabular_classification" + "tabular_classification", + "time_series_classification" ] } }, diff --git a/internal/schema/layout.v1.json b/internal/schema/layout.v1.json index 2ecdc199..bab2a92a 100644 --- a/internal/schema/layout.v1.json +++ b/internal/schema/layout.v1.json @@ -2,6 +2,7 @@ "tasks": { "causal_language_modeling": { "family": "text", + "grouping": null, "manifest": { "has_label_column": false, "kind": "labels_csv", @@ -21,6 +22,7 @@ }, "embeddings": { "family": "text", + "grouping": null, "manifest": { "has_label_column": false, "kind": "labels_csv", @@ -41,6 +43,7 @@ }, "image_classification": { "family": "image", + "grouping": null, "manifest": { "has_label_column": true, "kind": "labels_csv", @@ -52,6 +55,7 @@ }, "keypoint_detection": { "family": "image", + "grouping": null, "manifest": { "has_label_column": true, "kind": "labels_csv", @@ -63,6 +67,7 @@ }, "masked_language_modeling": { "family": "text", + "grouping": null, "manifest": { "has_label_column": false, "kind": "labels_csv", @@ -74,6 +79,7 @@ }, "object_detection": { "family": "image", + "grouping": null, "manifest": { "has_label_column": true, "kind": "labels_csv", @@ -92,6 +98,7 @@ }, "semantic_segmentation": { "family": "image", + "grouping": null, "manifest": { "has_label_column": true, "kind": "labels_csv", @@ -110,6 +117,7 @@ }, "sentence_pair_classification": { "family": "text", + "grouping": null, "manifest": { "has_label_column": true, "kind": "labels_csv", @@ -129,6 +137,7 @@ }, "seq2seq": { "family": "text", + "grouping": null, "manifest": { "has_label_column": false, "kind": "labels_csv", @@ -148,6 +157,7 @@ }, "tabular_classification": { "family": "tabular", + "grouping": null, "manifest": { "has_label_column": true, "kind": "data_csv", @@ -159,6 +169,7 @@ }, "tabular_regression": { "family": "tabular", + "grouping": null, "manifest": { "has_label_column": true, "kind": "data_csv", @@ -170,6 +181,7 @@ }, "text_classification": { "family": "text", + "grouping": null, "manifest": { "has_label_column": true, "kind": "labels_csv", @@ -179,8 +191,25 @@ "record_format": null, "sidecars": [] }, + "time_series_classification": { + "family": "tabular", + "grouping": { + "count_unit": "sequences", + "group_column": "sequence_id", + "time_column": "timestamp" + }, + "manifest": { + "has_label_column": true, + "kind": "data_csv", + "requires_filename_column": false + }, + "primary_subdir": null, + "record_format": null, + "sidecars": [] + }, "time_series_forecasting": { "family": "tabular", + "grouping": null, "manifest": { "has_label_column": true, "kind": "data_csv", @@ -192,6 +221,7 @@ }, "time_to_event_prediction": { "family": "tabular", + "grouping": null, "manifest": { "has_label_column": true, "kind": "data_csv", @@ -203,6 +233,7 @@ }, "token_classification": { "family": "text", + "grouping": null, "manifest": { "has_label_column": true, "kind": "labels_csv", @@ -213,5 +244,5 @@ "sidecars": [] } }, - "version": "1" + "version": "2" } diff --git a/scripts/.data-ingestors-ref b/scripts/.data-ingestors-ref index 843291e2..9e6d8b84 100644 --- a/scripts/.data-ingestors-ref +++ b/scripts/.data-ingestors-ref @@ -9,4 +9,12 @@ # # Format: the first non-comment, non-blank line is the ref (a full commit SHA # preferred; a branch name works but reintroduces floating drift). -efaeb07185c42556f833e876cb17791f30f4916d +# +# CURRENT PIN: the time_series_classification WS1 branch head +# (data-ingestors#359, backend#1054/#1056) — the schema gains the +# time_series_classification enum value + the sequence-column conditional, +# and layout.v1 gains the grouping trait (Decision-4). Coupled release +# (T16): once #359 merges, bump this to the merge commit on master and +# re-run scripts/sync-schema.sh (a squash merge leaves this PR-branch SHA +# fetchable, so CI stays green either way). +c38c8adb13f6d49c5c56119b1a20b3454beb7afb From 3ba1b63409a8925bcd6e3956f0dff0f0f2ac504f Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:30:48 +0200 Subject: [PATCH 15/22] fix(submit): make maxLine authoritative so the drain test isn't vacuous (review) (#212) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the earlier drain fix caught a vacuous test: bufio.Scanner's token cap is max(maxLine, cap(initialBuf)), so passing a 64 KB initial buffer with maxLine=1024 silently capped at 64 KB — the test's ~7.5 KB "oversized" line never tripped bufio.ErrTooLong, so the drain branch was never exercised (the test still passed with the drain deleted). Fix the helper so maxLine is authoritative: clamp the initial buffer to maxLine (64 KB otherwise). Production passes 16 MB, so the clamp is a no-op there — the initial buffer stays 64 KB and grows on demand exactly as before. The test now uses maxLine=4096 with a >4 KB line (guarded against future vacuity), which genuinely trips ErrTooLong. Verified by mutation: removing the drain now makes the test FAIL (InsertedRecords=0, the false exit 9), and restoring it passes. Co-authored-by: Claude Opus 4.8 --- internal/submit/watch.go | 11 ++++++++++- internal/submit/watch_display_test.go | 15 ++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/internal/submit/watch.go b/internal/submit/watch.go index 6f8dfa66..7083b0ae 100644 --- a/internal/submit/watch.go +++ b/internal/submit/watch.go @@ -504,7 +504,16 @@ const displayLineMax = 16 * 1024 * 1024 // non-nil error only on a genuine read failure (network drop, ctx cancel). func streamDisplayAndParse(r io.Reader, out io.Writer, maxLine int) error { scanner := bufio.NewScanner(r) - scanner.Buffer(make([]byte, 0, 64*1024), maxLine) + // bufio.Scanner's token cap is max(maxLine, cap(initialBuf)), so the initial + // buffer must never exceed maxLine or it would silently raise the effective + // cap above maxLine. Start at 64 KB (grows on demand) for the common case, + // but clamp it so maxLine stays authoritative — the display path relies on it + // (production passes 16 MB, so the clamp is a no-op there). + initCap := 64 * 1024 + if maxLine < initCap { + initCap = maxLine + } + scanner.Buffer(make([]byte, 0, initCap), maxLine) for scanner.Scan() { // scanner strips the trailing '\n'; re-add it. errcheck-friendly: the // write error is discarded because the exit code is the contract. diff --git a/internal/submit/watch_display_test.go b/internal/submit/watch_display_test.go index 6bf86c96..b08554e1 100644 --- a/internal/submit/watch_display_test.go +++ b/internal/submit/watch_display_test.go @@ -33,14 +33,23 @@ func (r *errAfterReader) Read(p []byte) (int, error) { // watch returns a false exit 9 on a healthy run. The drain-past-ErrTooLong must // keep pulling so the parser still resolves the summary. func TestStreamDisplayAndParse_DrainsPastOversizedLineSoParserSeesBanner(t *testing.T) { - oversized := strings.Repeat("\rprocessing... ", 500) // ~7 KB, no '\n' + // A tqdm-style progress "line": 500 '\r'-redraws, no '\n', ~7.5 KB. + oversized := strings.Repeat("\rprocessing... ", 500) + // maxLine is authoritative in streamDisplayAndParse (the initial buffer is + // clamped to it), so a small cap genuinely trips bufio.ErrTooLong and fires + // the drain. Guard it: a cap >= the line would make this test vacuously pass + // WITHOUT exercising the drain. Production uses 16 MB. + const maxLine = 4096 + if len(oversized) <= maxLine { + t.Fatalf("test setup: oversized line (%d B) must exceed maxLine (%d B) to trip ErrTooLong", + len(oversized), maxLine) + } stream := strings.NewReader(oversized + realIngestorBanner) parser := NewSummaryParser() tee := io.TeeReader(stream, parserWriter{parser: parser}) var out bytes.Buffer - // Tiny cap so the oversized line trips ErrTooLong (production uses 16 MB). - if err := streamDisplayAndParse(tee, &out, 1024); err != nil { + if err := streamDisplayAndParse(tee, &out, maxLine); err != nil { t.Fatalf("an over-long DISPLAY line must not be fatal; got: %v", err) } parser.FlushLine() From 2a5e0e621b455899ebc0478ba76faa16301790d9 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:31:00 +0200 Subject: [PATCH 16/22] chore(schema): pin data-ingestors to the #359 merge commit (#217) Closes the T16 coupled release opened by #216: the vendored schema was pinned to the WS1 PR-branch head pre-merge; data-ingestors#359 is now merged, so the pin moves to the merge commit on develop (7b4ecac21ee491998ea4252daace9a5af6a1cb4a). sync-schema.sh confirms both vendored files (ingest.v1.json, layout.v1.json) are byte-identical to the merge commit - no vendored content changes, ref-only. Co-authored-by: Claude Opus 4.8 --- scripts/.data-ingestors-ref | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/scripts/.data-ingestors-ref b/scripts/.data-ingestors-ref index 9e6d8b84..438a9e26 100644 --- a/scripts/.data-ingestors-ref +++ b/scripts/.data-ingestors-ref @@ -10,11 +10,9 @@ # Format: the first non-comment, non-blank line is the ref (a full commit SHA # preferred; a branch name works but reintroduces floating drift). # -# CURRENT PIN: the time_series_classification WS1 branch head -# (data-ingestors#359, backend#1054/#1056) — the schema gains the -# time_series_classification enum value + the sequence-column conditional, -# and layout.v1 gains the grouping trait (Decision-4). Coupled release -# (T16): once #359 merges, bump this to the merge commit on master and -# re-run scripts/sync-schema.sh (a squash merge leaves this PR-branch SHA -# fetchable, so CI stays green either way). -c38c8adb13f6d49c5c56119b1a20b3454beb7afb +# CURRENT PIN: the data-ingestors#359 merge commit on develop — +# time_series_classification WS1 shipped (backend#1054/#1056): schema enum + +# sequence-column conditional, layout.v1 grouping trait (Decision-4). This +# bump closes the T16 coupled release opened by cli#216 (which pinned the +# PR-branch head pre-merge). +7b4ecac21ee491998ea4252daace9a5af6a1cb4a From e1ea845dd91428ef0ae30e4959773e37e11fa262 Mon Sep 17 00:00:00 2001 From: "Asad Iqbal (Saadi)" Date: Fri, 10 Jul 2026 20:16:31 +0500 Subject: [PATCH 17/22] fix(push): fail closed when labels.csv can't be read mid-walk (text preflight) (#221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manifestReferencedTextNames skipped a failed CSV read with `continue`, returning a partial referenced set — so the text files named by the unread rows would silently escape the enforced record-format check (local fail-open), and a *persistent* read error would spin the loop forever. The image mirror-check (CrossCheckLabels) already aborts on the same read error; make the enforced-text path match and fail closed. The trigger is I/O, not malformed CSV content: openCSVReader sets LazyQuotes + FieldsPerRecord=-1, so every bad CSV shape parses cleanly (like pandas) — the branch is only reachable via a genuine read failure. The parse loop is split into referencedTextNames(*csv.Reader) so that branch can be exercised with an injected failing reader. Addresses the Bugbot finding on #219. Co-authored-by: Claude Opus 4.8 --- internal/push/text.go | 18 +++++++++++++++++- internal/push/text_test.go | 39 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/internal/push/text.go b/internal/push/text.go index 2c7a9b34..08e63700 100644 --- a/internal/push/text.go +++ b/internal/push/text.go @@ -1,6 +1,7 @@ package push import ( + "encoding/csv" "errors" "fmt" "io" @@ -192,6 +193,14 @@ func manifestReferencedTextNames(csvPath string) (map[string]struct{}, error) { return nil, fmt.Errorf("reading labels.csv: %w", err) } defer func() { _ = closer.Close() }() + return referencedTextNames(r) +} + +// referencedTextNames is the manifest walk over an already-opened reader — +// split out so the mid-stream read-error path (unreachable through a real +// file, since openCSVReader's LazyQuotes+FieldsPerRecord=-1 tolerate every +// malformed shape) can be exercised with an injected failing reader. +func referencedTextNames(r *csv.Reader) (map[string]struct{}, error) { r.LazyQuotes = true // read the rows pandas would, don't drop them header, err := r.Read() @@ -216,7 +225,14 @@ func manifestReferencedTextNames(csvPath string) (map[string]struct{}, error) { break } if err != nil { - continue // a row even LazyQuotes can't read is another check's diagnostic + // LazyQuotes + FieldsPerRecord=-1 make the reader tolerate every + // malformed CSV shape pandas does, so this is not a "bad row" — it + // is a genuine I/O read error partway through labels.csv. Skipping + // it would return a partial referenced set and silently leave the + // unread rows' text files unchecked — a local fail-open. The image + // mirror-check (CrossCheckLabels) aborts on the same read error; + // do the same here so the enforced-text path fails closed. + return nil, fmt.Errorf("reading labels.csv: %w", err) } if col >= len(rec) { continue diff --git a/internal/push/text_test.go b/internal/push/text_test.go index 4c228f57..abe9256f 100644 --- a/internal/push/text_test.go +++ b/internal/push/text_test.go @@ -1,6 +1,8 @@ package push import ( + "encoding/csv" + "errors" "os" "path/filepath" "strings" @@ -407,3 +409,40 @@ func TestBuild_Text_PassesSchema(t *testing.T) { Table: "t_emb", Category: "embeddings", Intent: "train", }, "texts", false) } + +// failAfterReader yields its data once, then returns err on every subsequent +// Read — simulating labels.csv failing to read partway through (the header +// parses, the body read then errors). +type failAfterReader struct { + data []byte + err error + done bool +} + +func (f *failAfterReader) Read(p []byte) (int, error) { + if !f.done { + f.done = true + return copy(p, f.data), nil + } + return 0, f.err +} + +// TestReferencedTextNames_ReadErrorFailsClosed: a mid-stream read error on +// labels.csv must abort the manifest walk (fail closed) rather than silently +// return a partial referenced set that leaves the unread rows' text files +// unvalidated — the image mirror-check (CrossCheckLabels) aborts on the same +// error, and the enforced-text path must match. The trigger is I/O, not +// malformed CSV: LazyQuotes + FieldsPerRecord=-1 parse every bad shape cleanly +// (like pandas), so the only way into this branch is a genuine read failure. +func TestReferencedTextNames_ReadErrorFailsClosed(t *testing.T) { + sentinel := errors.New("disk gave up mid-read") + r := csv.NewReader(&failAfterReader{data: []byte("filename,label\n"), err: sentinel}) + r.FieldsPerRecord = -1 // match openCSVReader + + if _, err := referencedTextNames(r); err == nil { + t.Fatal("referencedTextNames returned nil error on a mid-stream read failure; " + + "the manifest walk must fail closed") + } else if !errors.Is(err, sentinel) { + t.Errorf("error should wrap the underlying read failure, got: %v", err) + } +} From f05d01d8adddfcbbd9256233815ce38ec0146b24 Mon Sep 17 00:00:00 2001 From: "Asad Iqbal (Saadi)" Date: Fri, 10 Jul 2026 20:36:10 +0500 Subject: [PATCH 18/22] fix(push): fail closed on labels.csv read errors in label + sequence preflight (#222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more mirror-checks swallowed a non-EOF csv.Reader.Read error with `continue`, same class as #221: - readLabelColumnValues (CheckLabelDiversity / ReadLabelValues): a mid-read failure returned a PARTIAL class set with Found=true — under-counting classes could false-reject good data or pass a bad file. Now returns an error; the diversity GATE fails closed, while the value PREVIEW (ReadLabelValues) degrades to Found=false. - CheckSequenceRows: a mid-read failure skipped the unread tail, so null/missing sequence ids there never surfaced locally. Now fails closed. CrossCheckLabels and the tabular schema scan already abort on the same error; this brings the whole package to uniform fail-closed behavior. The trigger is I/O, not malformed CSV (LazyQuotes + FieldsPerRecord=-1 tolerate every bad shape). Each scan is split into a reader-based core (labelColumnValuesFrom / sequenceScanFrom) so the branch is exercised with an injected failing reader; parsing is unchanged and the value parity harness validates the relocation. Addresses the two follow-up Bugbot findings on #219. Co-authored-by: Claude Opus 4.8 --- internal/push/preflight.go | 66 ++++++++++++++++++++++++++------- internal/push/preflight_test.go | 45 ++++++++++++++++++++++ 2 files changed, 98 insertions(+), 13 deletions(-) diff --git a/internal/push/preflight.go b/internal/push/preflight.go index 84649d81..76007615 100644 --- a/internal/push/preflight.go +++ b/internal/push/preflight.go @@ -457,7 +457,13 @@ func TruncateList(items []string, max int) string { // even an empty string is a real class and every distinct trimmed string // counts. The caller derives the two flags from the label's schema type. func CheckLabelDiversity(csvPath, labelColumn string, dropNASentinels, collapseNumeric bool) error { - v := readLabelColumnValues(csvPath, labelColumn, dropNASentinels, collapseNumeric) + v, err := readLabelColumnValues(csvPath, labelColumn, dropNASentinels, collapseNumeric) + if err != nil { + // A mid-read failure leaves a PARTIAL column — trusting its class count + // would false-reject good data (under-counted classes) or pass a bad + // file. Fail closed, like the text preflight (#221) and CrossCheckLabels. + return err + } // Benign-skip when the column is absent (that's CheckLabelColumn's // diagnostic) or an unreadable file (another check's) — both leave Found // false. Two or more classes is diverse enough. @@ -490,7 +496,11 @@ type LabelReadValues struct { // readLabelColumnValues, so the value preview and the diversity verdict cannot // drift from each other. func ReadLabelValues(csvPath, labelColumn string, dropNASentinels, collapseNumeric bool) LabelReadValues { - return readLabelColumnValues(csvPath, labelColumn, dropNASentinels, collapseNumeric) + // The preview path tolerates a mid-read failure as "not found" (the + // diversity GATE, CheckLabelDiversity, is the one that fails closed on it); + // a partial preview simply reports nothing rather than a wrong count. + v, _ := readLabelColumnValues(csvPath, labelColumn, dropNASentinels, collapseNumeric) + return v } // readLabelColumnValues reads csvPath's label column once and returns its @@ -501,15 +511,29 @@ func ReadLabelValues(csvPath, labelColumn string, dropNASentinels, collapseNumer // the ingestor's per-column read). Unlike the previous early-exit diversity // scan, this reads the whole column to build the full class set + row count — // one scan now backs both the diversity verdict and the value-level preview. -func readLabelColumnValues(csvPath, labelColumn string, dropNASentinels, collapseNumeric bool) LabelReadValues { +func readLabelColumnValues(csvPath, labelColumn string, dropNASentinels, collapseNumeric bool) (LabelReadValues, error) { r, closer, err := openCSVReader(csvPath) if err != nil { - return LabelReadValues{} // Found=false: unreadable file is another check's diagnostic + return LabelReadValues{}, nil // Found=false: unreadable file is another check's diagnostic } defer func() { _ = closer.Close() }() + v, err := labelColumnValuesFrom(r, labelColumn, dropNASentinels, collapseNumeric) + if err != nil { + // Mid-read failure: the column is now partial. Fail closed rather than + // count a truncated class set (matches CrossCheckLabels / #221). + return LabelReadValues{}, fmt.Errorf("reading %s: %w", filepath.Base(csvPath), err) + } + return v, nil +} + +// labelColumnValuesFrom is the label scan over an already-opened reader — split +// out so the mid-stream read-error path can be exercised with an injected +// failing reader. A benign miss (no header, or the column absent) returns +// Found=false with a nil error; only a mid-scan read failure is a non-nil error. +func labelColumnValuesFrom(r *csv.Reader, labelColumn string, dropNASentinels, collapseNumeric bool) (LabelReadValues, error) { header, err := r.Read() if err != nil { - return LabelReadValues{} + return LabelReadValues{}, nil // no header (empty/unreadable) — another check's diagnostic } col, resolved := -1, "" for i, c := range header { @@ -528,7 +552,7 @@ func readLabelColumnValues(csvPath, labelColumn string, dropNASentinels, collaps } } if col == -1 { - return LabelReadValues{} // Found=false — benign skip, like the ingestor + return LabelReadValues{}, nil // Found=false — benign skip, like the ingestor } distinct := map[string]bool{} rowCount := 0 @@ -538,7 +562,7 @@ func readLabelColumnValues(csvPath, labelColumn string, dropNASentinels, collaps break } if err != nil { - continue + return LabelReadValues{}, err // caller wraps with the filename and fails closed } rowCount++ if len(rec) <= col { @@ -564,7 +588,7 @@ func readLabelColumnValues(csvPath, labelColumn string, dropNASentinels, collaps classes = append(classes, k) } sort.Strings(classes) - return LabelReadValues{Resolved: resolved, Classes: classes, RowCount: rowCount, Found: true} + return LabelReadValues{Resolved: resolved, Classes: classes, RowCount: rowCount, Found: true}, nil } // knownMediaExtensions mirrors the ingestor's FileExtension.get_all_extensions @@ -731,13 +755,29 @@ func CheckSequenceRows(csvPath, groupColumn string) (sequences int, err error) { return 0, nil // unreadable file is another check's diagnostic } defer func() { _ = closer.Close() }() + seqs, nullErr, readErr := sequenceScanFrom(r, groupColumn) + if readErr != nil { + // Mid-read failure: null/missing ids in the unread tail would never + // surface. Fail closed rather than pass a partial scan (matches + // CrossCheckLabels / #221). + return 0, fmt.Errorf("reading %s: %w", filepath.Base(csvPath), readErr) + } + return seqs, nullErr +} + +// sequenceScanFrom scans the group column over an already-opened reader — split +// out so the mid-stream read-error path can be exercised with an injected +// failing reader. nullErr is the domain rejection (empty/null ids); readErr is +// a mid-scan read failure the caller wraps with the filename. A benign miss (no +// header, or the column absent) returns zero values. +func sequenceScanFrom(r *csv.Reader, groupColumn string) (sequences int, nullErr, readErr error) { header, err := r.Read() if err != nil { - return 0, nil + return 0, nil, nil // no header — benign } col := matchColumnIndex(header, groupColumn) if col == -1 { - return 0, nil // benign skip — the schema checks own this diagnostic + return 0, nil, nil // benign skip — the schema checks own this diagnostic } distinct := map[string]bool{} nullCount, rowNum, firstNullRow := 0, 0, 0 @@ -747,7 +787,7 @@ func CheckSequenceRows(csvPath, groupColumn string) (sequences int, err error) { break } if err != nil { - continue + return 0, nil, err // caller wraps with the filename and fails closed } rowNum++ v := "" @@ -768,9 +808,9 @@ func CheckSequenceRows(csvPath, groupColumn string) (sequences int, err error) { "the sequence column %q has %d empty/null value(s) (first at data row %d). Every "+ "timestep row must carry the id of the sequence it belongs to — the cluster rejects "+ "this after the upload; fill in the ids and re-run.", - groupColumn, nullCount, firstNullRow) + groupColumn, nullCount, firstNullRow), nil } - return len(distinct), nil + return len(distinct), nil, nil } // PreflightProblem is a preflight rejection. BadFlag marks problems whose diff --git a/internal/push/preflight_test.go b/internal/push/preflight_test.go index 9fb5259a..45059954 100644 --- a/internal/push/preflight_test.go +++ b/internal/push/preflight_test.go @@ -1,6 +1,8 @@ package push import ( + "encoding/csv" + "errors" "image" "image/png" "os" @@ -577,3 +579,46 @@ func TestPreflightDataset_SequenceGrouped(t *testing.T) { t.Errorf("time_series_forecasting must stay ungrouped and diversity-free: %v", problem.Err) } } + +// TestLabelColumnValuesFrom_ReadErrorFailsClosed: a mid-scan read error on the +// label column must abort with an error (fail closed) rather than return a +// PARTIAL class set — a truncated count would false-reject good data or pass a +// bad file. Mirrors the text preflight (#221) and CrossCheckLabels. The trigger +// is I/O, not malformed CSV (LazyQuotes + FieldsPerRecord=-1 parse every bad +// shape), so it's exercised with an injected reader that fails after the header. +func TestLabelColumnValuesFrom_ReadErrorFailsClosed(t *testing.T) { + sentinel := errors.New("disk gave up mid-read") + r := csv.NewReader(&failAfterReader{data: []byte("label\n"), err: sentinel}) + r.FieldsPerRecord = -1 // match openCSVReader + + v, err := labelColumnValuesFrom(r, "label", false, false) + if err == nil { + t.Fatal("labelColumnValuesFrom returned nil error on a mid-scan read failure; must fail closed") + } + if !errors.Is(err, sentinel) { + t.Errorf("error should wrap the underlying read failure, got: %v", err) + } + if v.Found { + t.Error("a failed read must not report Found=true (a partial column)") + } +} + +// TestSequenceScanFrom_ReadErrorFailsClosed: the sequence-id scan must surface a +// mid-scan read error (readErr) rather than silently pass a partial scan whose +// unread tail could hide null ids. Same fail-closed contract as #221. +func TestSequenceScanFrom_ReadErrorFailsClosed(t *testing.T) { + sentinel := errors.New("disk gave up mid-read") + r := csv.NewReader(&failAfterReader{data: []byte("sequence_id\n"), err: sentinel}) + r.FieldsPerRecord = -1 // match openCSVReader + + _, nullErr, readErr := sequenceScanFrom(r, "sequence_id") + if readErr == nil { + t.Fatal("sequenceScanFrom returned nil readErr on a mid-scan read failure; must fail closed") + } + if !errors.Is(readErr, sentinel) { + t.Errorf("readErr should wrap the underlying read failure, got: %v", readErr) + } + if nullErr != nil { + t.Errorf("a read failure must not be reported as a null-id domain error: %v", nullErr) + } +} From b38d73604aa98ed4749eb09b87664095e30bfa2b Mon Sep 17 00:00:00 2001 From: "Asad Iqbal (Saadi)" Date: Fri, 10 Jul 2026 21:32:07 +0500 Subject: [PATCH 19/22] fix(push): SniffFamily mirrors the walk (symlinked/mis-cased/lone markers) (#223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(push): SniffFamily mirrors the walk for symlinked/mis-cased/lone markers SniffFamily inferred layout markers from the directory listing (e.type/ IsDir + a case-sensitive name compare), which diverged from the walk's os.Lstat-based, symlink-rejecting, case-following resolution — so several layouts sniffed "confident tabular" and got silently ingested as a table when the walk would have refused them or the data was actually image/text: - A symlinked images/ / texts/ / sequences/ (IsDir()==false) fell through to confident tabular → an image/text dataset ingested as a table, media dropped. - A plain file named images/… did the same. - A lone labels.csv with no media dir sniffed confident tabular, though labels.csv is the image/text manifest name (usually an incomplete media set). - labels.csv was matched case-sensitively while the walk resolves it case-insensitively on macOS/Windows (under-claimed there). - A dir whose only CSV was a symlink was counted, though DiscoverTabular rejects a symlinked CSV. Now media markers are probed with markerResolves (the walk's literal-lowercase Lstat), labels.csv with a new regularFileResolves (regular-file Lstat, so it tracks the walk's case behavior and rejects a symlink/dir), symlinked CSVs are skipped from the count, any marker-named entry the walk can't use is flagged ambiguous with a fix hint, and a lone labels.csv stays ambiguous. Generalises the mis-cased-marker fix (#203) to its symlink/file/lone-manifest siblings and resolves the open Bugbot finding on #219. Co-Authored-By: Claude Opus 4.8 * test(cli): match the generalized sniff hint wording (fix vs rename) The mis-cased-marker hint became a general badMarker hint covering symlinks and files too, where "rename" isn't the right verb — the message now says "fix it and ingest again". Update the resolveFamily consumer test's assertion to match (it fired on the case-sensitive-FS branch in Linux CI). Co-Authored-By: Claude Opus 4.8 * fix(push): count symlinked CSVs like findSingleCSV in the sniff Bugbot follow-up on #223: SniffFamily skipped symlinked .csv entries from csvCount, but findSingleCSV counts every non-dir .csv (symlinks included) toward its exactly-one rule. So a directory with one regular CSV plus a symlinked CSV sniffed confident tabular while DiscoverTabular rejects it as multi-CSV. Count symlinks too, and track csvSymlink so a lone symlinked CSV (which DiscoverTabular rejectSymlinks) stays ambiguous rather than confident. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- internal/cli/interactive_test.go | 4 +- internal/push/preview.go | 160 +++++++++++++++---------------- internal/push/preview_test.go | 78 +++++++++++++++ 3 files changed, 160 insertions(+), 82 deletions(-) diff --git a/internal/cli/interactive_test.go b/internal/cli/interactive_test.go index a0cce5ce..c5e673bb 100644 --- a/internal/cli/interactive_test.go +++ b/internal/cli/interactive_test.go @@ -242,7 +242,7 @@ func TestResolveFamily_SurfacesMiscasedHint(t *testing.T) { // Case-sensitive FS: the walk can't see Images/, so the sniff stays // ambiguous with a rename hint. resolveFamily must print it, and still // ask the family plainly (the hint is advisory, not a lock). - if !strings.Contains(buf.String(), "rename it and ingest again") { + if !strings.Contains(buf.String(), "fix it and ingest again") { t.Errorf("resolveFamily must surface the mis-cased rename hint; got:\n%s", buf.String()) } if !contains(f.asked, "What kind of data is this?") { @@ -254,7 +254,7 @@ func TestResolveFamily_SurfacesMiscasedHint(t *testing.T) { if fam != push.FamilyImage { t.Errorf("family = %v, want image (walk resolves the mis-cased folder here)", fam) } - if strings.Contains(buf.String(), "rename it and ingest again") { + if strings.Contains(buf.String(), "fix it and ingest again") { t.Errorf("no false rename hint when the walk sees the folder; got:\n%s", buf.String()) } if contains(f.asked, "What kind of data is this?") { diff --git a/internal/push/preview.go b/internal/push/preview.go index 259925d0..56539dae 100644 --- a/internal/push/preview.go +++ b/internal/push/preview.go @@ -97,79 +97,63 @@ func SniffFamily(path string) FamilySniff { if err != nil { return FamilySniff{} } - // Match the walk's markers with the literal, case-sensitive names it - // uses (filepath.Join + os.Lstat on "images" / "texts" / "sequences" / - // "labels.csv"). The .csv extension check is case-insensitive to mirror - // DiscoverTabular's EqualFold. - var hasImages, hasTexts, hasSequences, hasLabels bool - // miscasedMarker flags a subdir that matches a marker name only - // case-insensitively (e.g. "Images", "Texts") AND that the walk can't - // actually see. Whether the walk sees it is filesystem-dependent, so we - // don't guess from the on-disk casing — we probe the literal lowercase - // path the walk keys on (markerResolves, below). On a case-sensitive FS - // (Linux) a mis-cased dir is invisible to the walk, so its lone - // labels.csv would otherwise fall through to the confident-tabular - // branch and get silently ingested as a table, images/texts dropped — - // that's the footgun we flag. On a case-insensitive FS (macOS, Windows) - // the walk DOES resolve the mis-cased dir, so it's a real marker and we - // never set this. When set, stay ambiguous and ask the family plainly. - miscasedMarker := false - // miscasedName / miscasedCanonical hold the first mis-cased dir we see - // and the lowercase marker it resembles, so the ambiguous return can - // name the likely rename (e.g. "Images" → "images") in its Hint. - var miscasedName, miscasedCanonical string + // Mirror the walk's marker resolution instead of guessing from the + // directory listing. Discover / DiscoverText key on + // os.Lstat(filepath.Join(dir, "images"|"texts"|"sequences")) and reject a + // symlink, so a media marker is real only when that literal lowercase path + // resolves to a real directory — true for an exact lowercase dir and, on a + // case-insensitive FS (macOS, Windows), a mis-cased one; false for a + // symlinked, mis-cased-on-Linux, or plain-file "images". labels.csv is + // probed the same way (a regular file — DiscoverText rejectSymlinks it), so + // the label marker follows the walk's case behavior on this FS too. + hasImages := markerResolves(abs, "images") + hasTexts := markerResolves(abs, "texts") + hasSequences := markerResolves(abs, "sequences") + hasText := hasTexts || hasSequences + hasLabels := regularFileResolves(abs, "labels.csv") + + // badMarker flags an entry whose name matches a media marker (exactly or + // case-insensitively) but which the walk can't use — a symlink, a mis-cased + // dir on a case-sensitive FS, or a plain file. Such a layout is usually an + // image/text dataset whose media folder the walk won't see, and it must not + // masquerade as a lone-CSV table (#203 and its symlink/file siblings). + // badMarkerName/Canonical name the first one so the ambiguous return can + // point at the fix. + var badMarker bool + var badMarkerName, badMarkerCanonical string + // csvCount counts CSVs exactly as findSingleCSV does — every non-dir .csv, + // symlinks INCLUDED — so the sniff's exactly-one check agrees with the + // walk's (a lone regular CSV + a symlinked CSV is "multiple" to both). + // csvSymlink notes whether any counted CSV is a symlink: DiscoverTabular + // Lstat+rejectSymlinks the sole CSV, so a directory whose only CSV is a + // symlink is a layout the walk refuses — the sniff must not confidently + // promise tabular for it. csvCount := 0 + csvSymlink := false for _, e := range entries { name := e.Name() - if e.IsDir() { - switch name { - case "images": - hasImages = true - case "texts": - hasTexts = true - case "sequences": - hasSequences = true - default: - // The exact lowercase markers are matched by the cases above, so - // anything that EqualFolds a marker here is case-insensitive but - // NOT exact — e.g. "Images". Whether the walk actually SEES it is - // filesystem-dependent, so mirror the walk rather than guess: - // Discover keys on the literal lowercase name via - // os.Lstat(filepath.Join(dir, "images")). On a case-insensitive - // FS (macOS APFS, Windows) that resolves the mis-cased dir, so - // the walk accepts it — treat it as the real marker, exactly as - // Discover will (no false rename hint). Only when the lowercase - // literal does NOT resolve (a case-sensitive FS, e.g. Linux) is - // this a genuine footgun the walk can't see: flag it ambiguous - // and name the likely rename. - if m := markerFold(name); m != "" { - if markerResolves(abs, m) { - switch m { - case "images": - hasImages = true - case "texts": - hasTexts = true - case "sequences": - hasSequences = true - } - } else { - miscasedMarker = true - if miscasedName == "" { - miscasedName, miscasedCanonical = name, m - } - } + if m := markerFold(name); m != "" { + // A marker-named entry. If the walk's literal probe already resolved + // it (hasImages/hasTexts/hasSequences above) it's a real marker; + // otherwise the walk can't see a usable marker dir here — flag it. + if !markerResolves(abs, m) { + badMarker = true + if badMarkerName == "" { + badMarkerName, badMarkerCanonical = name, m } } continue } - if name == "labels.csv" { - hasLabels = true + if e.IsDir() { + continue } if isCSV(name) { csvCount++ + if e.Type()&os.ModeSymlink != 0 { + csvSymlink = true + } } } - hasText := hasTexts || hasSequences // An images/ directory is the image layout's tell; a texts/ or // sequences/ directory is the text family's. Both require labels.csv @@ -193,27 +177,32 @@ func SniffFamily(path string) FamilySniff { // chosen task disagree. return FamilySniff{Family: FamilyText, Confident: true, Echo: fmt.Sprintf("Found labels.csv and a %s folder — this looks like text data.", dir)} - case !hasImages && !hasText && !miscasedMarker && csvCount == 1: - // Exactly one CSV, mirroring DiscoverTabular's findSingleCSV rule. - // Two or more CSVs is a directory the tabular walk rejects, so stay - // ambiguous rather than confidently promise a layout it refuses. - // A mis-cased marker dir alongside the CSV (miscasedMarker) also - // bails to ambiguous: the lone labels.csv of an image/text layout - // whose media folder was mis-cased must not masquerade as a table. - return FamilySniff{Family: FamilyTabular, Confident: true, - Echo: "Found a CSV table — this is tabular data."} - case miscasedMarker: - // A subdir matches a media marker case-insensitively but not exactly - // (e.g. "Images/"), AND markerResolves already confirmed the walk's - // literal lowercase path does NOT resolve on this filesystem — so the - // walk genuinely won't see this folder. An image/text layout here would + case badMarker: + // A marker-named entry the walk can't use (mis-cased on a case-sensitive + // FS, a symlink, or a plain file). An image/text layout here would // otherwise look like a lone-CSV table and be silently ingested as one - // (#203). Stay ambiguous, but point at the likely rename so the user can - // fix the layout rather than just being asked a blind question. + // (#203 and its symlink/file siblings). Stay ambiguous, and name the + // likely fix rather than asking a blind question. return FamilySniff{Hint: fmt.Sprintf( - "Found a %q folder — image and text data use a lowercase folder like %q. "+ - "If that's your data folder, rename it and ingest again.", - miscasedName, miscasedCanonical)} + "Found a %q entry, but image and text data need a real lowercase folder like %q "+ + "(not a symlink or a file). If that's your data folder, fix it and ingest again.", + badMarkerName, badMarkerCanonical)} + case !hasImages && !hasText && csvCount == 1 && hasLabels: + // The one CSV is labels.csv — the image/text MANIFEST name — with no + // images/, texts/, or sequences/ folder beside it. That is far more + // likely an image/text dataset missing its media folder than a table + // that happens to be named labels.csv, so don't silently ingest it as a + // table: stay ambiguous and say what's missing. + return FamilySniff{Hint: "Found only labels.csv and no images/, texts/, or sequences/ " + + "folder next to it. If this is image or text data, add its media folder beside " + + "labels.csv; if it's genuinely a table, choose tabular below."} + case !hasImages && !hasText && csvCount == 1 && !csvSymlink: + // Exactly one non-manifest, non-symlink CSV in a directory, mirroring + // DiscoverTabular (findSingleCSV's exactly-one count + its rejectSymlink + // on that CSV). Two or more CSVs — or a lone symlinked one — is a layout + // the tabular walk refuses, so stay ambiguous rather than promise it. + return FamilySniff{Family: FamilyTabular, Confident: true, + Echo: "Found a CSV table — this is tabular data."} default: return FamilySniff{} } @@ -231,6 +220,17 @@ func markerResolves(dir, canonical string) bool { return err == nil && fi.IsDir() } +// regularFileResolves reports whether name resolves to a REGULAR file under dir +// via the walk's own os.Lstat(filepath.Join(dir, name)) — used for labels.csv. +// IsRegular() is false for a directory and for a symlink, matching DiscoverText +// (which Lstats labels.csv and rejectSymlinks it, and errors if it's a dir); on +// a case-insensitive FS the Lstat also resolves "Labels.csv", so the label +// marker tracks the walk's case behavior exactly as the media markers do. +func regularFileResolves(dir, name string) bool { + fi, err := os.Lstat(filepath.Join(dir, name)) + return err == nil && fi.Mode().IsRegular() +} + // markerFold returns the media-folder marker (images / texts / sequences) // that name matches ignoring case, or "" if none. Used only to detect a // mis-cased marker dir and name the correct lowercase form in the hint; the diff --git a/internal/push/preview_test.go b/internal/push/preview_test.go index b2816081..ddd3e2ad 100644 --- a/internal/push/preview_test.go +++ b/internal/push/preview_test.go @@ -232,6 +232,84 @@ func TestSniffFamily(t *testing.T) { } }) + t.Run("lone labels.csv (no media dir) is ambiguous, not a table", func(t *testing.T) { + // labels.csv is the image/text MANIFEST name; a folder with only it and + // no images/texts/sequences is far more likely an incomplete image/text + // dataset than a table. The sniff must ask, not confidently ingest it as + // a table (which would drop the intended media silently). + dir := t.TempDir() + writePrev(t, filepath.Join(dir, "labels.csv"), "filename,label\na.jpg,cat\n") + if s := SniffFamily(dir); s.Confident { + t.Fatalf("lone labels.csv must be ambiguous, got %+v", s) + } else if s.Hint == "" || !strings.Contains(s.Hint, "labels.csv") { + t.Fatalf("hint should explain the missing media folder, got %q", s.Hint) + } + // Contrast: a single NON-manifest CSV is still confident tabular. + dir2 := t.TempDir() + writePrev(t, filepath.Join(dir2, "patients.csv"), "age,label\n1,c\n") + if s := SniffFamily(dir2); !s.Confident || s.Family != FamilyTabular { + t.Fatalf("a single non-labels CSV should stay confident tabular, got %+v", s) + } + }) + + t.Run("symlinked media marker + labels.csv is ambiguous (walk can't use it)", func(t *testing.T) { + // A symlinked images/ is not a real directory to the walk (Lstat + + // rejectSymlink), so it must not fall through to confident tabular and + // silently ingest labels.csv as a table — the symlink sibling of #203. + dir := t.TempDir() + writePrev(t, filepath.Join(dir, "labels.csv"), "filename,label\na.jpg,cat\n") + if err := os.Symlink(t.TempDir(), filepath.Join(dir, "images")); err != nil { + t.Skipf("symlink unsupported on this platform: %v", err) + } + if s := SniffFamily(dir); s.Confident { + t.Fatalf("a symlinked images/ marker must be ambiguous, got %+v", s) + } else if !strings.Contains(s.Hint, "images") { + t.Fatalf("hint should name the unusable images marker, got %q", s.Hint) + } + }) + + t.Run("a plain file named images is ambiguous (not a usable marker)", func(t *testing.T) { + dir := t.TempDir() + writePrev(t, filepath.Join(dir, "labels.csv"), "filename,label\na.jpg,cat\n") + writePrev(t, filepath.Join(dir, "images"), "not a folder") + if s := SniffFamily(dir); s.Confident { + t.Fatalf("a plain file named images must be ambiguous, got %+v", s) + } + }) + + t.Run("dir whose only csv is a symlink is ambiguous (walk rejects it)", func(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(t.TempDir(), "real.csv") + writePrev(t, target, "a,b\n1,2\n") + if err := os.Symlink(target, filepath.Join(dir, "data.csv")); err != nil { + t.Skipf("symlink unsupported on this platform: %v", err) + } + if s := SniffFamily(dir); s.Confident { + t.Fatalf("a dir whose only csv is a symlink must be ambiguous, got %+v", s) + } + }) + + t.Run("regular csv + symlinked csv is ambiguous (walk counts both → multiple)", func(t *testing.T) { + // findSingleCSV counts every non-dir .csv, symlinks included, so a + // regular CSV plus a symlinked one is "multiple CSVs" to the walk. The + // sniff must count them the same way and stay ambiguous, not sniff + // confident tabular off the lone regular CSV. (#223 Bugbot follow-up.) + dir := t.TempDir() + writePrev(t, filepath.Join(dir, "data.csv"), "a,b\n1,2\n") + target := filepath.Join(t.TempDir(), "extra.csv") + writePrev(t, target, "c,d\n3,4\n") + if err := os.Symlink(target, filepath.Join(dir, "link.csv")); err != nil { + t.Skipf("symlink unsupported on this platform: %v", err) + } + if s := SniffFamily(dir); s.Confident { + t.Fatalf("regular + symlinked CSV must be ambiguous, got %+v", s) + } + // And the walk it mirrors rejects the same dir as multi-CSV. + if _, err := DiscoverTabular(dir); err == nil { + t.Fatal("DiscoverTabular should reject a dir with a regular + a symlinked CSV") + } + }) + t.Run("missing path is ambiguous", func(t *testing.T) { if s := SniffFamily(filepath.Join(t.TempDir(), "nope")); s.Confident { t.Fatalf("missing path should be ambiguous, got %+v", s) From b0ffc489e7fe22c9d1d0f6b903527688db2129ba Mon Sep 17 00:00:00 2001 From: "Asad Iqbal (Saadi)" Date: Fri, 10 Jul 2026 21:32:16 +0500 Subject: [PATCH 20/22] fix(cli,submit): reject misapplied task flags; don't false-fail slow-but-ok jobs (#224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli,submit): reject misapplied task flags; don't false-fail slow-but-ok jobs Five findings from a full adversarial review of the v0.8.0 payload: cli/data.go — task-scoped flags (--schema, --label-policy, --time-column, --number-of-keypoints, and --label-column on self-supervised text) were read only inside their one category branch, so passing one on a task that doesn't use it silently dropped the value and the user's intent — despite the help text saying each is scoped. Reject them explicitly (exit 2), mirroring the existing --target-size/--min-size guard and spec.go's build gates. cli/interactive.go — pickTask mapped the chosen display name back to a task ID through a DisplayName→ID map; two tasks sharing a display name would collide and return the wrong ID. Map by the offered option's position instead. submit/watch.go — * finalJobStatus timing out on a slow apiserver returned Unknown → a false exit 9 even when the ingest actually succeeded. Fall back to the Pod phase (Succeeded/Failed) before giving up. The 30s bound is now a package var so the fallback is unit-testable. * the most-recent-Pod selection tied on CreationTimestamp's 1s granularity; a same-second backoffLimit retry could leave the watch on the old Failed Pod. Break the tie toward the live/succeeded Pod. Tests added for every case (wrong-task rejection + a correct-task negative control; the pod-phase fallback; the same-second tie-break). Addresses review findings on #219 (C1/C2/D1/D2 + the --label-column echo). Co-Authored-By: Claude Opus 4.8 * fix(submit): re-select the current pod in the Unknown-status fallback Bugbot follow-up on #224: the pod-phase fallback read the podName captured at the start of the watch. waitForJobPod can return an early Failed Pod while a backoffLimit retry is still Pending, so classifying off that stale name could report Failed for a run that ultimately succeeds. Re-list Pods and re-select the current most-recent useful one (extracted as mostRecentUsefulPod, shared with waitForJobPod) so the fallback reflects the retry's real outcome. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- internal/cli/data.go | 33 ++++++++++ internal/cli/data_test.go | 42 +++++++++++++ internal/cli/interactive.go | 17 ++++-- internal/submit/watch.go | 77 +++++++++++++++++++---- internal/submit/watch_test.go | 112 ++++++++++++++++++++++++++++++++++ 5 files changed, 262 insertions(+), 19 deletions(-) diff --git a/internal/cli/data.go b/internal/cli/data.go index a96bee51..f857abad 100644 --- a/internal/cli/data.go +++ b/internal/cli/data.go @@ -593,6 +593,39 @@ collaborators can train against that table without ever seeing the raw files.`)) } } + // Task-scoped flags. Like --target-size/--min-size above, each of these is + // read only inside the one category branch that consumes it, so passing one + // on a task that doesn't use it silently dropped the value — and the user's + // intent — with no error, even though the help text says each is scoped. + // Reject a misapplied flag explicitly so it fails fast instead of being + // ignored (the scope mirrors spec.go's build gates exactly). + if a.SchemaFlag != "" && !push.IsTabular(a.Spec.Category) { + return &exitError{code: 2, err: fmt.Errorf( + "--schema is tabular/time-series tasks only; it doesn't apply to task %q", a.Spec.Category)} + } + if a.Spec.LabelPolicy != "" && !push.IsRegressionClass(a.Spec.Category) { + return &exitError{code: 2, err: fmt.Errorf( + "--label-policy is regression-class tasks only (tabular_regression, "+ + "time_series_forecasting, time_to_event_prediction); it doesn't apply to task %q", + a.Spec.Category)} + } + if a.Spec.TimeColumn != "" && a.Spec.Category != "time_to_event_prediction" { + return &exitError{code: 2, err: fmt.Errorf( + "--time-column is time_to_event_prediction only; it doesn't apply to task %q", a.Spec.Category)} + } + if a.Spec.NumberOfKeypoints != 0 && a.Spec.Category != "keypoint_detection" { + return &exitError{code: 2, err: fmt.Errorf( + "--number-of-keypoints is keypoint_detection only; it doesn't apply to task %q", a.Spec.Category)} + } + // --label-column is meaningless for self-supervised text (the label is the + // text itself); buildText drops it, so accepting it silently discarded the + // user's value and the review echoed a column that never shipped. + if a.Spec.LabelColumn != "" && push.SelfSupervisedText(a.Spec.Category) { + return &exitError{code: 2, err: fmt.Errorf( + "--label-column doesn't apply to task %q — it trains on the text itself, with no label column", + a.Spec.Category)} + } + // 3. Walk the local directory FIRST (local "fail fast"), dispatched // by category family. Image categories expect labels.csv + // images/; tabular / time-series categories expect a single diff --git a/internal/cli/data_test.go b/internal/cli/data_test.go index 1ca119c1..1837fb59 100644 --- a/internal/cli/data_test.go +++ b/internal/cli/data_test.go @@ -619,3 +619,45 @@ func TestPrintLocalSummary_ShowsDetectedExtension(t *testing.T) { t.Errorf("summary missing detected extension:\n%s", buf.String()) } } + +// TestDataIngest_WrongTaskFlags_ExitTwo pins the task-scoped flag guards: a +// flag scoped to one task family must be REJECTED (exit 2) on a task that +// doesn't consume it, rather than silently dropped. Previously only +// --target-size/--min-size were guarded; the others were parsed only inside +// their category branch, so the value (and the user's intent) vanished with no +// error even though the help text says each is scoped. +func TestDataIngest_WrongTaskFlags_ExitTwo(t *testing.T) { + root := imgcLayout(t) + for _, tc := range []struct { + name string + args []string + }{ + {"schema-on-image", []string{"--task=image_classification", "--label-column=label", "--schema=age:INT"}}, + {"label-policy-on-image", []string{"--task=image_classification", "--label-column=label", "--label-policy=passthrough"}}, + {"time-column-on-image", []string{"--task=image_classification", "--label-column=label", "--time-column=t"}}, + {"keypoints-on-image-classification", []string{"--task=image_classification", "--label-column=label", "--number-of-keypoints=17"}}, + {"label-column-on-self-supervised-text", []string{"--task=masked_language_modeling", "--label-column=sentiment"}}, + } { + t.Run(tc.name, func(t *testing.T) { + code, _, _ := execDataIngest(t, append([]string{root, "--name=t1", "--intent=train"}, tc.args...)) + if code != 2 { + t.Fatalf("expected exit 2 for misapplied flag (%s), got %d", tc.name, code) + } + }) + } +} + +// TestDataIngest_ScopedFlag_OnCorrectTask_NotRejected is the negative control: +// a scoped flag on its VALID task must NOT trip the guard. --number-of-keypoints +// on keypoint_detection passes the guard and falls through to the (bad) +// kubeconfig → exit 3, proving the guard didn't over-reject with exit 2. +func TestDataIngest_ScopedFlag_OnCorrectTask_NotRejected(t *testing.T) { + root := imgcLayout(t) + code, _, _ := execDataIngest(t, []string{ + root, "--name=t1", "--intent=train", + "--task=keypoint_detection", "--label-column=label", "--number-of-keypoints=17", + }) + if code == 2 { + t.Fatal("--number-of-keypoints on keypoint_detection must not be rejected as a wrong-task flag") + } +} diff --git a/internal/cli/interactive.go b/internal/cli/interactive.go index fda85562..c52f23af 100644 --- a/internal/cli/interactive.go +++ b/internal/cli/interactive.go @@ -263,20 +263,25 @@ func pickTask(p *ui.Printer, pr prompter, fam push.Family) (string, error) { } opts := make([]string, len(available)) - byName := make(map[string]string, len(available)) for i, s := range available { opts[i] = s.DisplayName() - byName[s.DisplayName()] = s.ID } ans, err := pr.Select("Which task?", "pick the task this data is for", opts, opts[0]) if err != nil { return "", err } - if id, ok := byName[ans]; ok { - return id, nil + // Map the answer back to a task by POSITION, not through a + // DisplayName→ID map: two tasks in a family could in principle share a + // display name, and a map would silently keep only the last, returning the + // wrong ID for the first. Matching the offered option by index returns the + // one the user actually saw (the list above is in this same order). + for i, o := range opts { + if o == ans { + return available[i].ID, nil + } } - // Defensive: an answer that isn't one of the offered display names. - // Never return an empty category — fall back to the first available. + // Defensive: an answer that isn't one of the offered options. Never return + // an empty category — fall back to the first available. return available[0].ID, nil } diff --git a/internal/submit/watch.go b/internal/submit/watch.go index 7083b0ae..3cf1fa72 100644 --- a/internal/submit/watch.go +++ b/internal/submit/watch.go @@ -305,10 +305,39 @@ func WatchJob( // The fresh ctx still propagates SIGINT (parent is customerCtx, // which carries signal.NotifyContext's cancel); a Ctrl-C in this // window falls into the detach branches below. - finalCtx, finalCancel := context.WithTimeout(customerCtx, 30*time.Second) + finalCtx, finalCancel := context.WithTimeout(customerCtx, finalJobStatusTimeout) defer finalCancel() outcome, statusErr := finalJobStatus(finalCtx, cs, namespace, jobName) + // If the Job controller hasn't posted a terminal condition within the + // budget (a slow/contended apiserver), finalJobStatus returns Unknown — + // which the orchestrator maps to a failure exit (9). The Pod itself is + // authoritative for whether the ingest actually finished, so fall back to + // its phase: a Succeeded Pod is a successful run, a Failed one a failure. + // RE-LIST and re-select here rather than trusting the podName captured at + // the start of the watch: waitForJobPod may have returned an early Failed + // Pod while a backoffLimit retry was still Pending, so the current + // most-recent Pod (e.g. the retry that Succeeded) is the honest signal. A + // fresh short ctx is used since finalCtx may be at its deadline; it still + // derives from customerCtx, so a Ctrl-C here leaves Unknown intact and + // drops into the detach handling below. + if statusErr == nil && outcome == JobOutcomeUnknown { + podCtx, podCancel := context.WithTimeout(customerCtx, 5*time.Second) + if pods, perr := cs.CoreV1().Pods(namespace).List(podCtx, metav1.ListOptions{ + LabelSelector: "job-name=" + jobName, + }); perr == nil { + if best := mostRecentUsefulPod(pods.Items); best != nil { + switch best.Status.Phase { + case corev1.PodSucceeded: + outcome = JobOutcomeSucceeded + case corev1.PodFailed: + outcome = JobOutcomeFailed + } + } + } + podCancel() + } + // A non-ctx log-stream error is incidental if the Job still // reached a terminal state. Previously ANY such error (e.g. // "container is terminated" once the Pod was replaced by a retry) @@ -398,17 +427,7 @@ func waitForJobPod(ctx context.Context, cs kubernetes.Interface, namespace, jobN // don't count — they have no logs to stream yet, so // we keep polling until they either transition or // become irrelevant. - var bestPod *corev1.Pod - for i := range pods.Items { - p := &pods.Items[i] - switch p.Status.Phase { - case corev1.PodRunning, corev1.PodSucceeded, corev1.PodFailed: - if bestPod == nil || - p.CreationTimestamp.After(bestPod.CreationTimestamp.Time) { - bestPod = p - } - } - } + bestPod := mostRecentUsefulPod(pods.Items) if bestPod == nil { return false, nil // all Pods still Pending } @@ -422,6 +441,33 @@ func waitForJobPod(ctx context.Context, cs kubernetes.Interface, namespace, jobN return podName, podPhase, nil } +// mostRecentUsefulPod picks the Pod whose logs/phase best represent the run +// among a Job's Pods (they share the job-name label; a backoffLimit retry +// yields several). "Useful phase" = Running | Succeeded | Failed — Pending +// Pods have no logs yet. The most recent wins; on a CreationTimestamp tie +// (1s granularity), a live/succeeded Pod beats a Failed one so a same-second +// retry isn't overshadowed by the attempt it replaced. Returns nil when every +// Pod is still Pending. +func mostRecentUsefulPod(pods []corev1.Pod) *corev1.Pod { + var best *corev1.Pod + for i := range pods { + p := &pods[i] + switch p.Status.Phase { + case corev1.PodRunning, corev1.PodSucceeded, corev1.PodFailed: + switch { + case best == nil: + best = p + case p.CreationTimestamp.After(best.CreationTimestamp.Time): + best = p + case p.CreationTimestamp.Time.Equal(best.CreationTimestamp.Time) && + best.Status.Phase == corev1.PodFailed && p.Status.Phase != corev1.PodFailed: + best = p + } + } + } + return best +} + // streamPodLogsAndParse opens a streaming log read on the Pod and // pipes it through (a) a TeeReader to `out` for verbatim display // and (b) a Summary parser for the 📊 banner. Returns the parsed @@ -547,6 +593,11 @@ func (pw parserWriter) Write(b []byte) (int, error) { return len(b), nil } +// finalJobStatusTimeout bounds the post-stream wait for the Job's terminal +// condition. A package var (not a const) purely so tests can shrink it; it is +// never overridden in production. +var finalJobStatusTimeout = 30 * time.Second + // finalJobStatus does a bounded poll on the Job's status to // determine Succeeded vs Failed after log streaming ends. This is // a separate step because the log-stream-end doesn't always race @@ -554,7 +605,7 @@ func (pw parserWriter) Write(b []byte) (int, error) { // apiserver to post the terminal phase. func finalJobStatus(ctx context.Context, cs kubernetes.Interface, namespace, jobName string) (JobOutcome, error) { var outcome JobOutcome - err := wait.PollUntilContextTimeout(ctx, JobPollInterval, 30*time.Second, true, + err := wait.PollUntilContextTimeout(ctx, JobPollInterval, finalJobStatusTimeout, true, func(ctx context.Context) (bool, error) { job, err := cs.BatchV1().Jobs(namespace).Get(ctx, jobName, metav1.GetOptions{}) if err != nil { diff --git a/internal/submit/watch_test.go b/internal/submit/watch_test.go index 28cb19f8..5f466cbd 100644 --- a/internal/submit/watch_test.go +++ b/internal/submit/watch_test.go @@ -395,3 +395,115 @@ func TestParserWriter_FeedsParser(t *testing.T) { t.Errorf("TotalRecords = %d, want 1234 (via parserWriter)", got.TotalRecords) } } + +// TestWaitForJobPod_SameSecondTiePrefersLive: CreationTimestamp is 1s-granular, +// so a backoffLimit retry created in the same second as the failed Pod it +// replaces ties on .After. Without a tie-break the watch can latch onto the old +// Failed Pod (List order is unspecified). The live Pod must win the tie. #219. +func TestWaitForJobPod_SameSecondTiePrefersLive(t *testing.T) { + ts := metav1.NewTime(time.Now().Truncate(time.Second)) + failed := jobPod("a-failed", "ingestor", corev1.PodFailed) + failed.CreationTimestamp = ts + running := jobPod("b-running", "ingestor", corev1.PodRunning) + running.CreationTimestamp = ts + cs := fake.NewClientset(failed, running) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + name, phase, err := waitForJobPod(ctx, cs, "tracebloc", "ingestor") + if err != nil { + t.Fatalf("waitForJobPod: %v", err) + } + if name != "b-running" || phase != corev1.PodRunning { + t.Fatalf("got %s/%s, want b-running/Running (live Pod wins a same-second tie over Failed)", name, phase) + } +} + +// TestWatchJob_UnknownJobFallsBackToPodPhase: the Job controller hasn't posted a +// terminal condition (slow/contended apiserver) but the Pod already Succeeded. +// WatchJob must fall back to the Pod phase and report Succeeded rather than +// Unknown — which the orchestrator maps to a false exit 9. #219. +func TestWatchJob_UnknownJobFallsBackToPodPhase(t *testing.T) { + prev := finalJobStatusTimeout + finalJobStatusTimeout = 150 * time.Millisecond + defer func() { finalJobStatusTimeout = prev }() + + cs := fake.NewClientset( + jobPod("ingestor-fast", "ingestor", corev1.PodSucceeded), + // A Job with no terminal condition yet — finalJobStatus times out Unknown. + &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: "ingestor", Namespace: "tracebloc"}}, + ) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var out bytes.Buffer + wr, err := WatchJob(ctx, cs, "tracebloc", "ingestor", &out, nil) + if err != nil { + t.Fatalf("WatchJob: %v", err) + } + if wr.Outcome != JobOutcomeSucceeded { + t.Fatalf("Outcome = %v, want Succeeded (pod-phase fallback when the Job condition lags)", wr.Outcome) + } +} + +// TestMostRecentUsefulPod covers the selection the Unknown-fallback and +// waitForJobPod share: the newest useful-phase Pod wins, a same-second tie goes +// to the live/succeeded Pod over a Failed one, and all-Pending yields nil. +func TestMostRecentUsefulPod(t *testing.T) { + now := time.Now() + mk := func(name string, phase corev1.PodPhase, age time.Duration) corev1.Pod { + p := jobPod(name, "ingestor", phase) + p.CreationTimestamp = metav1.NewTime(now.Add(-age)) + return *p + } + + // Newer Succeeded beats older Failed. + if best := mostRecentUsefulPod([]corev1.Pod{ + mk("old-failed", corev1.PodFailed, 10*time.Minute), + mk("new-ok", corev1.PodSucceeded, 1*time.Minute), + }); best == nil || best.Name != "new-ok" { + t.Fatalf("want new-ok, got %v", best) + } + // Same-second tie → live/succeeded over Failed, regardless of slice order. + ts := metav1.NewTime(now.Truncate(time.Second)) + f := jobPod("f", "ingestor", corev1.PodFailed) + f.CreationTimestamp = ts + s := jobPod("s", "ingestor", corev1.PodSucceeded) + s.CreationTimestamp = ts + if best := mostRecentUsefulPod([]corev1.Pod{*f, *s}); best == nil || best.Name != "s" { + t.Fatalf("same-second tie should prefer the succeeded pod, got %v", best) + } + // All Pending → nil (no logs/phase to attach to yet). + if best := mostRecentUsefulPod([]corev1.Pod{mk("p", corev1.PodPending, time.Minute)}); best != nil { + t.Fatalf("all-Pending must yield nil, got %v", best) + } +} + +// TestWatchJob_UnknownFallbackPrefersSucceededOverFailed: the Unknown-timeout +// fallback must re-list Pods and pick the current most-recent useful one, not +// classify off a stale Failed Pod. With an older Failed Pod and a newer +// Succeeded one and no Job condition, WatchJob must report Succeeded. #224. +func TestWatchJob_UnknownFallbackPrefersSucceededOverFailed(t *testing.T) { + prev := finalJobStatusTimeout + finalJobStatusTimeout = 150 * time.Millisecond + defer func() { finalJobStatusTimeout = prev }() + + now := time.Now() + failed := jobPod("ingestor-failed", "ingestor", corev1.PodFailed) + failed.CreationTimestamp = metav1.NewTime(now.Add(-5 * time.Minute)) + succeeded := jobPod("ingestor-retry-ok", "ingestor", corev1.PodSucceeded) + succeeded.CreationTimestamp = metav1.NewTime(now.Add(-1 * time.Minute)) + cs := fake.NewClientset( + failed, succeeded, + &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: "ingestor", Namespace: "tracebloc"}}, // no terminal condition + ) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var out bytes.Buffer + wr, err := WatchJob(ctx, cs, "tracebloc", "ingestor", &out, nil) + if err != nil { + t.Fatalf("WatchJob: %v", err) + } + if wr.Outcome != JobOutcomeSucceeded { + t.Fatalf("Outcome = %v, want Succeeded (fallback must prefer the newer Succeeded pod over the old Failed one)", wr.Outcome) + } +} From ef672f28f3d7813031e41eca71ee6bc96a130e07 Mon Sep 17 00:00:00 2001 From: "Asad Iqbal (Saadi)" Date: Fri, 10 Jul 2026 21:32:28 +0500 Subject: [PATCH 21/22] fix(push): set LazyQuotes on CSV readers so a bare quote doesn't false-reject (#225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause behind the read-error findings: openCSVReader set FieldsPerRecord=-1 but NOT LazyQuotes, so a bare/unescaped quote in a row — which pandas (the ingestor) tolerates — made csv.Reader.Read return a parse error. Every preflight mirror-check (CrossCheckLabels, readLabelColumnValues, CheckSequenceRows, CheckHasDataRows, ReadCSVHeader) therefore diverged from the ingestor in the inverse direction: it REJECTED a layout the cluster ingests cleanly. It also meant the fail-closed change earlier in this PR was reachable via a bare-quote row, turning a fail-open into a false-reject. Setting LazyQuotes centrally in openCSVReader (and in InferSchema's own reader, which doesn't use it) makes the readers pandas-faithful; the remaining non-EOF Read error is then only ever genuine I/O, so failing closed on it is correct. Added a parity guard test (bare-quote CSV → read like pandas, not rejected). The full value/verdict parity harness still passes, confirming LazyQuotes does not over-accept relative to the ingestor. Co-authored-by: Claude Opus 4.8 --- internal/push/preflight.go | 10 +++++++--- internal/push/preflight_test.go | 23 +++++++++++++++++++++++ internal/push/tabular.go | 1 + 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/internal/push/preflight.go b/internal/push/preflight.go index 76007615..d51fc8c9 100644 --- a/internal/push/preflight.go +++ b/internal/push/preflight.go @@ -35,9 +35,12 @@ var utf8BOM = []byte{0xEF, 0xBB, 0xBF} // idiom the pandas-backed checks share (cli#71): pandas strips the BOM even // under encoding="utf-8", so a BOM'd file must read as if it had none or the // CLI would reject what the cluster accepts. FieldsPerRecord is -1 so a ragged -// row is a per-row concern, not an abort. The caller closes the returned -// Closer. A caller that must read the rows pandas tolerates (an unescaped -// quote) sets r.LazyQuotes = true before its first Read. +// row is a per-row concern, not an abort, and LazyQuotes is on so a row pandas +// tolerates (an unescaped/bare quote) is read here too rather than turned into +// a parse error — otherwise these mirror-checks would reject a layout the +// ingestor ingests cleanly (the inverse fail direction). With both set, a +// non-EOF Read error is only ever a genuine I/O failure, so callers can treat +// it as fail-closed. The caller closes the returned Closer. func openCSVReader(path string) (*csv.Reader, io.Closer, error) { f, err := os.Open(path) if err != nil { @@ -49,6 +52,7 @@ func openCSVReader(path string) (*csv.Reader, io.Closer, error) { } r := csv.NewReader(br) r.FieldsPerRecord = -1 + r.LazyQuotes = true // read the rows pandas would, don't drop or error on them return r, f, nil } diff --git a/internal/push/preflight_test.go b/internal/push/preflight_test.go index 45059954..b5bad5a7 100644 --- a/internal/push/preflight_test.go +++ b/internal/push/preflight_test.go @@ -622,3 +622,26 @@ func TestSequenceScanFrom_ReadErrorFailsClosed(t *testing.T) { t.Errorf("a read failure must not be reported as a null-id domain error: %v", nullErr) } } + +// TestOpenCSVReader_LazyQuotesMatchesPandas: a bare/unescaped quote in a data +// row is tolerated by pandas (the ingestor), so the CLI's mirror-checks must +// read the row rather than error on it. Before openCSVReader set LazyQuotes, +// CheckLabelDiversity/CrossCheckLabels/CheckSequenceRows all errored on such a +// row → false-rejecting a dataset the cluster accepts. Guard against regressing +// to the strict reader. +func TestOpenCSVReader_LazyQuotesMatchesPandas(t *testing.T) { + // Row 2's filename cell has a bare quote pandas keeps; the label column has + // two distinct classes across the three rows. + csvBody := "label,filename\ncat,a.jpg\ndog,b\"x.jpg\ncat,c.jpg\n" + p := writeTmp(t, "labels.csv", []byte(csvBody)) + + // Diversity must NOT false-reject: all 3 rows read → classes {cat,dog}. + if err := CheckLabelDiversity(p, "label", false, false); err != nil { + t.Errorf("bare-quote row must be read like pandas, not rejected: %v", err) + } + v := ReadLabelValues(p, "label", false, false) + if !v.Found || v.RowCount != 3 || len(v.Classes) != 2 { + t.Errorf("bare-quote CSV misread: Found=%v RowCount=%d classes=%v (want 3 rows, 2 classes)", + v.Found, v.RowCount, v.Classes) + } +} diff --git a/internal/push/tabular.go b/internal/push/tabular.go index b60679f6..928ac206 100644 --- a/internal/push/tabular.go +++ b/internal/push/tabular.go @@ -295,6 +295,7 @@ func InferSchema(csvPath string) (*SchemaInference, error) { r := csv.NewReader(f) r.FieldsPerRecord = -1 // ragged rows are CheckDuplicateHeaders' / read-time's diagnostic, not ours + r.LazyQuotes = true // read the rows pandas would; a bare quote must not abort inference header, err := r.Read() if err != nil { return nil, fmt.Errorf("reading CSV header from %s: %w", csvPath, err) From e4d5ce576ec4ec47c1c025634452110754c2fe8c Mon Sep 17 00:00:00 2001 From: "Asad Iqbal (Saadi)" Date: Fri, 10 Jul 2026 21:34:25 +0500 Subject: [PATCH 22/22] fix(summary): bound SummaryParser.buf on newline-less log floods (D3, #226) (#227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SummaryParser.Feed accumulated bytes into p.buf until a '\n'. The display path caps an oversized tqdm line via displayLineMax (16 MB) in streamDisplayAndParse and drains the rest, but the drained bytes still flow through the TeeReader into Feed — so an ingestor emitting many MB of '\r'-redraws with no '\n' for the life of a (up to 1h) run grew p.buf without the display-side cap ever applying to the parser. Bound buf at parserLineMax (= displayLineMax; same package, referenced directly so the two paths can't drift). When the partial newline-less line passes the ceiling, drop it and enter drop-until-newline mode so the oversized line's tail is discarded too rather than parsed as a spurious fresh line; FlushLine honors the same state at EOF. A real banner line is tens of bytes, so newline-less content past 16 MB can never be one — dropping is safe and the parser still recovers to parse the closing banner once a '\n' finally lands. Adds a white-box test feeding a >2x-parserLineMax newline-less flood, asserting buf stays bounded and a real banner after the flood parses. Deferred finding D3 from the v0.8.0 review (#220). Low severity. Co-authored-by: Claude Opus 4.8 --- internal/submit/summary.go | 52 +++++++++++++++++++++++++++++++-- internal/submit/summary_test.go | 45 ++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/internal/submit/summary.go b/internal/submit/summary.go index 0f19042c..6168e936 100644 --- a/internal/submit/summary.go +++ b/internal/submit/summary.go @@ -210,8 +210,28 @@ type SummaryParser struct { // genuinely 0 (early failure case) — Bugbot caught this on // PR #10 round 2. sawAnyField bool + + // droppingLine is set when buf outgrew parserLineMax before a + // '\n' arrived: the partial (newline-less) line was discarded + // (see Feed). It stays set until the terminating '\n' is seen, + // so the oversized line's tail is dropped too rather than + // parsed as a spurious fresh line. + droppingLine bool } +// parserLineMax bounds the partial (newline-less) content the +// parser buffers in buf. It mirrors displayLineMax (watch.go) +// exactly — same package, so we reference it directly and the two +// paths can't drift. Rationale: the display side drains a tqdm +// '\r'-redraw burst that outgrows displayLineMax rather than +// failing, but those drained bytes still flow through the +// TeeReader into Feed. Without a matching bound here, a +// pathological ingestor emitting many MB of '\r' redraws with no +// '\n' for the life of a (up to 1h) run would grow buf without +// limit. A real banner line is tens of bytes; newline-less content +// past this ceiling cannot be one, so Feed drops it. +const parserLineMax = displayLineMax + // NewSummaryParser returns an initialized parser. Caller's // goroutine owns it for the duration of the watch loop. func NewSummaryParser() *SummaryParser { @@ -229,10 +249,27 @@ func (p *SummaryParser) Feed(b []byte) { for { idx := bytes.IndexByte(p.buf.Bytes(), '\n') if idx < 0 { - // No complete line yet — wait for more input. + // No complete line yet. If the partial (newline-less) + // content has outgrown parserLineMax it cannot be a + // banner line — drop it and enter drop-until-newline + // mode so the oversized line's tail is discarded too, + // not mistaken for a fresh line. Bounds buf at + // parserLineMax + one Feed chunk regardless of how long + // the ingestor withholds a '\n'. + if p.buf.Len() > parserLineMax { + p.buf.Reset() + p.droppingLine = true + } return } line := p.buf.Next(idx + 1) // consume up to and including '\n' + if p.droppingLine { + // This '\n' terminates an oversized line whose head we + // already dropped; discard the buffered tail and resume + // normal parsing on the lines that follow. + p.droppingLine = false + continue + } p.feedLine(string(bytes.TrimRight(line, "\n"))) } } @@ -242,7 +279,18 @@ func (p *SummaryParser) Feed(b []byte) { // terminated without a final '\n' (rare but possible if the // container's stdout was killed mid-write). func (p *SummaryParser) FlushLine() { - if p.buf.Len() > 0 && !p.finalized { + if p.finalized { + return + } + if p.droppingLine { + // EOF landed mid-drop: buf holds the tail of an oversized + // line whose head was already discarded. Drop the tail too + // rather than parse it as a line. + p.buf.Reset() + p.droppingLine = false + return + } + if p.buf.Len() > 0 { p.feedLine(p.buf.String()) p.buf.Reset() } diff --git a/internal/submit/summary_test.go b/internal/submit/summary_test.go index 58ab6b73..9a34ce07 100644 --- a/internal/submit/summary_test.go +++ b/internal/submit/summary_test.go @@ -174,6 +174,51 @@ func TestSummaryParser_PostBannerLogsIgnored(t *testing.T) { } } +// TestSummaryParser_BufferBoundedOnNewlinelessFlood pins finding D3 +// (deferred from the v0.8.0 review): a pathological ingestor can emit +// many MB of tqdm '\r'-redraws with no '\n' for the life of a run. The +// display path drains those past displayLineMax, but the drained bytes +// still flow through the TeeReader into Feed — so without a matching +// bound the parser's buf would grow unbounded. Assert buf stays within +// parserLineMax under the flood, and that a real banner arriving after +// the flood terminates (a '\n' finally lands) still parses. +func TestSummaryParser_BufferBoundedOnNewlinelessFlood(t *testing.T) { + p := NewSummaryParser() + + // tqdm-style redraw: '\r' + progress text, never a '\n'. Feed well + // past parserLineMax in bounded chunks so we also exercise the + // across-Feed-calls accumulation, not just one giant Write. + chunk := []byte("\r" + strings.Repeat("#", 512*1024-1)) // 512 KiB, no '\n' + for total := 0; total <= parserLineMax*2; total += len(chunk) { + p.Feed(chunk) + if p.buf.Len() > parserLineMax { + t.Fatalf("buf grew to %d bytes after a newline-less flood, exceeds parserLineMax=%d", + p.buf.Len(), parserLineMax) + } + } + + // The flood is one newline-less line; none of it should have been + // mistaken for a banner. + if got := p.Result(); got != nil { + t.Fatalf("newline-less flood produced a non-nil Summary: %+v", got) + } + + // The pathological line finally terminates and a real banner + // follows. The parser must recover: drop the oversized line's tail, + // then parse the banner that comes after. + p.Feed([]byte("\n")) + p.Feed([]byte(realIngestorBanner)) + + got := p.Result() + if got == nil { + t.Fatal("banner after a newline-less flood did not parse; Result is nil") + } + if got.TotalRecords != 1234 || got.FailedRecords != 30 { + t.Errorf("post-flood banner parsed wrong: TotalRecords=%d FailedRecords=%d, want 1234/30", + got.TotalRecords, got.FailedRecords) + } +} + // TestStripANSI: the parser strips ANSI SGR codes from each line // before matching. Validate the regex handles common shapes. func TestStripANSI(t *testing.T) {