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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.)

Expand Down
11 changes: 11 additions & 0 deletions internal/push/category.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
12 changes: 7 additions & 5 deletions internal/push/category_registry_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand All@@ -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) {
Expand Down
28 changes: 28 additions & 0 deletions internal/push/layout_contract.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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.
Expand Down
38 changes: 38 additions & 0 deletions internal/push/layout_contract_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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")
}
}
125 changes: 124 additions & 1 deletion internal/push/preflight.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).
Expand DownExpand Up@@ -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,
Expand Down
Loading
Loading