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