diff --git a/agent-skills/DESIGN.md b/agent-skills/DESIGN.md index 78f4ca2f8..10aaf11d0 100644 --- a/agent-skills/DESIGN.md +++ b/agent-skills/DESIGN.md @@ -42,5 +42,21 @@ rules; this is why they exist. | Predict in notebook | one submission call, no execution | the predict call is part of the artifact (router Step 2) | user decision 2026-09-23 | | Snowflake ML Jobs | no scheduling | the artifact may run as an ML Job on request; a recurring run is proposed and created only on explicit confirmation (`hosts/snowflake-coco/ml_job.md`) | spec feature 7 (2026-10-13). How Kumo reaches the job container is pending; the skill names it only as host values (`adapter.md` §10) | +| Preflight source | read-only SQL against the user's source | on the generic host, whatever read tool the agent has; with none, the structure the user pastes (`preflight.md` §0) | added 2026-10-20 with the generic host. A coding agent beside the user often has no warehouse session; asking for access or details keeps the gate instead of guessing | + Accommodation, not deviation: the spec has the user select the table mode. Nobody does, so it defaults to `all-connected` and is confirmed at Node 1c. + +## Hosts and the access check + +The core says *what* intake and code generation need; a host says *how* in its `adapter.md` +(intake) and `runtime.md` (Step 2). A package ships one host, so the agent never weighs another +host's rules, and the core names it only as `hosts//` (`tests/test_package_skill.py`). + +Preflight §0 asks the agent to prove it can read the source before inferring anything from it. +CoCo always can — its SQL is the read tool — so §0 only adds the grant-or-details ask-back for an +unreadable scope. A coding agent may have an authenticated CLI, a tool server, Python with Kumo, +or nothing. Without the check it falls back on its priors, the failure seen when CoCo read an +empty skill tree. The agent's access is never the artifact's: the generated code authenticates +where it runs, so §0 changes no gate and no generated line. Credentials stay with the user — +the agent may ask them to authenticate a tool, never for a value. diff --git a/agent-skills/README.md b/agent-skills/README.md index c47199823..f52a24faf 100644 --- a/agent-skills/README.md +++ b/agent-skills/README.md @@ -1,8 +1,12 @@ # Agent skills: `kumo-data-science` Agent skill for building, training, and running inference with Kumo models. One shared source tree -serves every host; host-specific behavior lives under `skills/hosts/`. Currently shipped to -Snowflake Cortex Code (CoCo). +serves every host; host-specific behavior lives under `skills/hosts/`. Two hosts: + +| Host | Package | For | +|---|---|---| +| `hosts/snowflake-coco/` | `coco` | Snowflake Cortex Code (CoCo), inside Snowflake | +| `hosts/generic/` | `generic` | a coding agent beside the user — Claude Code, Codex — generating code for any of the six Kumo connectors | This directory is deliberately not under `.agents/skills/` or `.claude/skills/`: Codex, Claude Code and the CoCo CLI load those for repo developers, and this skill triggers on "kumo" and "train a @@ -19,7 +23,7 @@ are sub-skills and references it loads: |---|---| | `kumo-data-science/` | Router: intake, validation, preflight, notebook generation | | `kumoai-reference/` | SDK reference the generated code is checked against | -| `hosts/` | Host-specific behavior (currently Snowflake CoCo) | +| `hosts/` | Host-specific behavior: access, source and destination forms, preflight, connect step | | `kumo-model-explanations/` | Explaining predictions | | `kumo-training-diagnostics/` | Diagnosing training runs | | `kumo-model-persistence/` | Saving and reloading trained models | @@ -34,9 +38,15 @@ path. Build a host package with: ```bash python3 agent-skills/scripts/package_skill.py coco # -> agent-skills/dist/coco/ +python3 agent-skills/scripts/package_skill.py generic # -> agent-skills/dist/generic/skills/ python3 agent-skills/scripts/package_skill.py --check # build every host, verify references ``` +A package carries exactly one host, and the core reaches it only as `hosts//`; tests fail if +a core file names a host. To use the `generic` package, copy +`dist/generic/skills/kumo-data-science/` into the agent's user-level skills directory (for Claude +Code, `~/.claude/skills/`) — not into this repository, for the reason above. + The build stamps the router's `SKILL-BUILD` marker with the source commit (`-`, `-dirty` for uncommitted edits); never edit it by hand. Every build fails if a markdown link or a backticked package path does not resolve inside the diff --git a/agent-skills/scripts/package_skill.py b/agent-skills/scripts/package_skill.py index e837536c6..fc67661d7 100755 --- a/agent-skills/scripts/package_skill.py +++ b/agent-skills/scripts/package_skill.py @@ -5,6 +5,7 @@ """Build the kumo-data-science skill package for one host and check that it resolves. python3 agent-skills/scripts/package_skill.py coco # build into agent-skills/dist/ + python3 agent-skills/scripts/package_skill.py generic # Claude Code, Codex, other agents python3 agent-skills/scripts/package_skill.py coco --out DIR # build somewhere else python3 agent-skills/scripts/package_skill.py --check # build every host in a temp dir @@ -49,6 +50,11 @@ hosts=['snowflake-coco/adapter.md', 'snowflake-coco/runtime.md', 'snowflake-coco/ml_job.md'], capability=True), + # A coding agent outside any data platform. The package root holds the one skill directory, + # ready to copy into the agent's skills directory. + 'generic': dict(prefix='skills', + hosts=['generic/adapter.md', 'generic/runtime.md'], + capability=True), } MARKER = re.compile(r'SKILL-BUILD: [^`*\s]+') diff --git a/agent-skills/skills/hosts/generic/adapter.md b/agent-skills/skills/hosts/generic/adapter.md new file mode 100644 index 000000000..256bf2df8 --- /dev/null +++ b/agent-skills/skills/hosts/generic/adapter.md @@ -0,0 +1,143 @@ +# Host Adapter — Generic coding agent + +For a coding agent working beside the user — Claude Code, Codex, or any agent with a shell and +file tools — rather than inside a data platform. The agent produces code that runs in **any** +Python environment where Kumo is present, against whichever of the six Kumo connectors the data +calls for. The agent does not know where that code will run; the user does. + +Kumo is already present in the environment that runs the artifact; never raise environment setup +with the user. Generating the artifact — connect step, credentials, destinations and read-back — +is in `runtime.md` in this directory; read it at Step 2. + +This host has no job file: it does not submit, schedule or monitor jobs. When asked for one, say +so and offer the notebook or script. + +--- + +## 1. Source scope + +`source_scope` names a system and a location in it. Map it to its connector +(`kumoai-reference/references/kumo_finetuning_sdk.md` §3, §3.4): + +| The user names | Connector | +|---|---| +| A local directory of CSV or Parquet files | `FileConnector` | +| `s3://bucket/prefix` | `S3Connector` | +| `gs://bucket/prefix` | `GCSConnector` | +| A BigQuery project and dataset | `BigQueryConnector` | +| A Databricks catalog and schema | `DatabricksConnector` | +| A Snowflake database and schema | `SnowflakeConnector` | + +- A dotted name with no system named (`SALES.EVENTS`) fits Snowflake, BigQuery and Databricks + alike. Ask which system holds it — that one question, nothing else. A system already named in + any turn, or evident from the user's tools or files, settles it. +- A connector name on its own is not a location on this host, which keeps no registry of named + connectors. Ask what system and location it points at. +- A Snowflake semantic view resolves through `kumo-data-science/accept/semantic_view.md`, which + needs Snowflake SQL (§2). Without it, the §2 details are the view's base tables, their keys and + its relationships. +- Values only the runtime needs — a Snowflake warehouse, a Databricks SQL warehouse or cluster, a + `connections.toml` entry — are not part of `source_scope` and are never asked for at intake. + `runtime.md` §1 handles them. + +ADLS has no Kumo connector. Data there is outside this host's reach: say so. + +--- + +## 2. Access + +Implements `kumo-data-science/accept/preflight.md` §0. Check, in this order, and stop at the +first that reads the **columns and types** inside `source_scope`. A listing of object names +(`aws s3 ls`, `gcloud storage ls`) proves access, not structure: keep going down the list for a +reader, and if none is found, send the ask-back below with **Details** as the way forward. + +1. **A tool in this session** that reads the system — a server or connector tool for that + warehouse or storage. Use only its read operations. +2. **A command-line tool on the `PATH`** that is already authenticated (table below). Find it + with `command -v `, then run its check command against `source_scope` itself — a + general "who am I" call proves the login, not access to the data. +3. **Python in this session** in which `import kumo.engine` works and the provider's ambient + credentials resolve. Build the connector exactly as the artifact would (`runtime.md` §2) and + read `connector.table_names()` and `connector[''].columns`, which gives each column's + name and source type. Catalog reads only: no `Table`, `Graph` or snapshot. + +| Source | Tool | Check, inside `source_scope` | Columns and types | Declared keys | §3 probe | +|---|---|---|---|---|---| +| Local files | your file tools; `duckdb`, or Python with `pyarrow` | list the directory | CSV header line; Parquet schema (`duckdb -c "DESCRIBE SELECT * FROM ''"`) | none | `duckdb`, or `pyarrow` | +| S3 | `aws` | `aws s3 ls s3:////` | file names only — not enough on its own; for columns, a reader of `s3://` with the same credentials (`duckdb`, or check 3), else the ask-back | none | the same reader, else skip | +| GCS | `gcloud` | `gcloud storage ls gs:////` | as S3, with `gs://` | none | as S3 | +| BigQuery | `bq` | `bq ls :` | `bq show --schema --format=prettyjson :.
` | `INFORMATION_SCHEMA.TABLE_CONSTRAINTS` and `KEY_COLUMN_USAGE` of the dataset | `bq query --nouse_legacy_sql` | +| Databricks | `databricks` | `databricks tables list ` | `databricks tables get ..
` | the same output's table constraints | only through a SQL warehouse the user named; else skip | +| Snowflake | `snow` | `snow sql -q ""` | the preflight §2 query | `SHOW PRIMARY KEYS` / `SHOW IMPORTED KEYS IN SCHEMA .` | `snow sql -q` | + +A skipped probe leaves its candidates `[inferred, unverified]`, as preflight §0 says. A BigQuery +probe is billed by the bytes it scans; probe only the candidate columns. + +**What a check may do.** Read catalog metadata and run the preflight §3 probe — nothing else. +Never run a command that writes, creates, copies or exports data; never sign in, switch a +profile or account, or edit a tool's configuration; never add or update software to get a +reader. Tool permission prompts are the user's to answer; a refused prompt means that path is +unavailable. + +**When nothing reads the source's columns**, send the preflight §0 ask-back. State what you checked and +what happened — "no BigQuery tool in this session; `bq ls my-proj:sales` returned `Access +Denied`" — then offer: + +1. **Access**: authenticate the named command-line tool as an identity that can read the scope, + or connect a tool for that system to this session, and tell you when it is done. The user + does this; you never handle the credential. +2. **Details**: paste the structure. No rows are needed. Useful forms: DDL, `DESCRIBE` output, + `bq show --schema` JSON, a Parquet schema, CSV header lines — with any declared keys. + +--- + +## 3. Preflight + +Answer `kumo-data-science/accept/preflight.md` §1-§6 with the tool §2 found. Write table and +column names exactly as that tool returns them; the artifact must use the same spelling +(`kumoai-reference/references/kumo_finetuning_sdk.md` §3.2). Probe results are evidence for the +proposal only: never written, never saved to a file, never fed to the artifact. + +For files and object stores, a table is a file or a directory of parts in one format, below the +source location. **Name it without its file suffix**: `accounts.parquet` is the table `accounts`. +The connector finds the file from the bare name, and the name you pass becomes the table's name +in the graph, the edges and the PQL, where a suffix's dot does not parse. Show the bare names in +the proposal. + +--- + +## 4. Destinations + +Any destination a Kumo connector writes +(`kumoai-reference/references/kumo_finetuning_sdk.md` §9.2): a table in Snowflake, BigQuery or +Databricks, or a path in a local directory, S3 or GCS. The destination may be in a different +system from the source; it gets its own connector. Not destinations here: ADLS, and BigQuery for +anything but predictions and embeddings. + +A path destination ends in `/` (a directory) or in `.parquet` / `.csv` (one file). The sink adds +`.parquet` to any other path, so the file would not be where the user said. When the user gives +a path with neither, restate it with a trailing `/` at Node 1d and let them confirm or correct it. + +--- + +## 5. Runtime column constraints + +None known for this host. Do not exclude text columns pre-emptively. If a run fails while loading +a text encoder, report the error and the column; that finding belongs at Node 1c. + +--- + +## 6. Capability skills + +The capability skills were written for, and measured on, Snowflake. Their Kumo calls hold for +every connector. Where a step names a Snowflake registry, stage, session or table, follow it only +when the source or destination is Snowflake. For any other connector, say that the step is not +covered on this host; do not adapt it. + +--- + +## 7. Other deployment modes — out of scope + +Hosted Kumo — `kumo.init(...)`, an API key, `KUMO_API_KEY` / `KUMO_API_ENDPOINT`, Kumo Studio — and +the Snowflake Native App are not this SDK. If the user is on one of them, say so and stop. Never +blend their setup into generated code. diff --git a/agent-skills/skills/hosts/generic/runtime.md b/agent-skills/skills/hosts/generic/runtime.md new file mode 100644 index 000000000..8cb5e407d --- /dev/null +++ b/agent-skills/skills/hosts/generic/runtime.md @@ -0,0 +1,278 @@ +# Host Runtime — Generic coding agent + +Read at Step 2, when generating the artifact: where it runs, the config cell, the connect step, +credentials, destinations and read-back. Intake is in `adapter.md`. + +--- + +## 0. Where the code runs + +Unknown to you, and it does not matter: the artifact must run unchanged in any Python environment +where `import kumo.engine` works and the source's credentials resolve — a workstation, a VM, a +hosted notebook. So it assumes no platform: + +- no Snowpark session from `get_active_session()`, no `dbutils`, no SPCS token, no notebook + magics, no platform secret store; +- no path outside the ones confirmed at intake, except Kumo's scratch default + (`kumoai-reference/references/kumo_finetuning_sdk.md` §2.1). + +Your own session's access (`adapter.md` §2) says nothing about the runtime's. The connect step +resolves credentials where the code runs. + +Write the artifact to the path the user names, else to the current working directory, named after +the task (`churn_30d_train.ipynb`). Write only that file. Creating it is not running it: Step 3 +still waits for an explicit request. + +--- + +## 1. Config cell + +The first code cell holds the imports and every location as a named constant: the confirmed +source, the confirmed destination, and any population table — each written verbatim. + +Values only the runtime knows — a Snowflake warehouse or `connections.toml` entry, a Databricks +SQL warehouse or cluster — are constants here too. S3, GCS and BigQuery have none: the ambient +chain resolves credentials and region where the code runs (an instance or job role, the +environment, `aws configure`, `gcloud`), and BigQuery bills queries to the confirmed dataset's own +project. So add no region, profile, project or billing constant for them beyond the confirmed +location, and pass `region=` or `billing_project_id=` only when the user named one. For the +values that are constants, write the user's value if they gave one; otherwise a placeholder of +the form `''`, and list each placeholder in your reply as something to +fill in before running. A placeholder never stands in for a confirmed +value, and never for a secret. + +End the cell with a guard, so a run with an unfilled value stops before reading anything: + +```python +CONFIG = dict(SOURCE_DIR=SOURCE_DIR, OUTPUT_DIR=OUTPUT_DIR) # every constant in this cell +unfilled = [k for k, v in CONFIG.items() if isinstance(v, str) and v.startswith('<')] +assert not unfilled, f'Fill in before running: {unfilled}' +``` + +--- + +## 2. Connect step + +One connector per system, built from the confirmed `source_scope` with ambient credentials +(`kumoai-reference/references/kumo_finetuning_sdk.md` §3, §3.4). Signatures are there; do not +add arguments the user did not confirm. + +```python +# Local files +connector = kumo.FileConnector(root_dir=SOURCE_DIR) +# S3 / GCS — rooted at the confirmed prefix, so table_names() works +connector = kumo.S3Connector(root_dir=SOURCE_URI) # 's3://bucket/prefix/'; no region= +connector = kumo.GCSConnector(root_dir=SOURCE_URI) # 'gs://bucket/prefix/' +# BigQuery — PROJECT_ID and DATASET_ID are the two parts of the confirmed `project.dataset` scope, +# never placeholders. The identity is required; with no arguments it is the ambient Google +# credential. No billing_project_id (§1). +connector = kumo.BigQueryConnector(project_id=PROJECT_ID, dataset_id=DATASET_ID, + identity=kumo.BigQueryIdentity()) +# Databricks — exactly one of warehouse_id / cluster_id +connector = kumo.DatabricksConnector( + catalog=CATALOG, schema_name=SCHEMA, identity=kumo.DatabricksIdentity(), + warehouse_id=DATABRICKS_WAREHOUSE_ID) +# Snowflake — outside Snowflake's own runtime, through a connections.toml entry +connector = kumo.SnowflakeConnector.from_connection_toml( + connection_name=SNOWFLAKE_CONNECTION, warehouse=SNOWFLAKE_WAREHOUSE, + database=DATABASE, schema_name=SCHEMA) # lower case: keep the quotes, §3 + +assert connector.table_names(), 'Connector reached no tables.' +``` + +Emit only the line for the confirmed system. Never emit a rootless S3 or GCS connector for a +source: it cannot list tables. + +Name file and object-store tables without their suffix — `connector['accounts']` for +`accounts.parquet` — and use those names in the tables, edges and PQL (`adapter.md` §3). + +**Credentials.** The ambient forms above name no secret. Use an explicit identity (§3.4) only +when the user asks for one, and then read each secret at run time from an environment variable +whose **name** is a config-cell constant — never a literal, never a value asked for in chat: + +```python +identity = kumo.S3Identity(access_key_id=os.environ[AWS_KEY_ID_VAR], + secret_access_key=os.environ[AWS_SECRET_VAR]) +``` + +The Snowflake factory `from_spcs_env` and Snowflake's notebook secret store belong to +Snowflake's own runtime. Emit them only when the user says the code runs there. + +### 2.1 Storage + +Kumo's scratch directory defaults to `~/.cache/kumo-engine`. Emit `kumo.configure(scratch_dir=...)` +only for a scratch path the user named. A `FileConnector` source or destination must not overlap +it (§3.4). Scratch is disposable: anything that must outlive the run is written to the confirmed +destination. + +--- + +## 3. Destinations and read-back + +Build the output connector for the destination's own system, and the `PredictionOutputConfig` in +that connector's form (`kumoai-reference/references/kumo_finetuning_sdk.md` §9.2). Then read the +destination back and compare its row count to `prediction_result.num_rows` — the only proof the +write worked. + +**A path destination is written at exactly the confirmed path**, never inside it. Root the output +connector at the destination's **parent** and name the destination's last segment in +`output_paths`, keeping its trailing `/` for a directory: + +```python +# confirmed destination: s3://bucket/prediction_outputs/churn_scores/ +OUTPUT_PARENT, OUTPUT_LEAF = 's3://bucket/prediction_outputs/', 'churn_scores/' +output_connector = kumo.S3Connector(root_dir=OUTPUT_PARENT) # FileConnector / GCSConnector alike +output_config = kumo.PredictionOutputConfig( + connector=output_connector, output_paths={'predictions': OUTPUT_LEAF}) +OUTPUT_LOCATION = OUTPUT_PARENT + OUTPUT_LEAF # the confirmed path, for read-back +``` + +Rooting the connector at the destination itself and passing a `table_name` writes one level +below it, to a path the user never confirmed. A directory destination must be empty or absent; +the sink creates it. + +`OUTPUT_LEAF` ends in `/`, `.parquet` or `.csv` (`adapter.md` §4). File, S3 and GCS sinks alike +add `.parquet` to any other leaf, and the read-back would then miss the file. The suffix also +sets the format the sink writes, so read back in that format. + +```python +# Local files, S3, GCS — directory or single-file output +import pyarrow.dataset as ds +OUTPUT_FORMAT = 'csv' if OUTPUT_LOCATION.rstrip('/').lower().endswith('.csv') else 'parquet' +written = ds.dataset(OUTPUT_LOCATION, format=OUTPUT_FORMAT).count_rows() # full path or URI + +# BigQuery +from google.cloud import bigquery +written = next(iter(bigquery.Client(project=PROJECT_ID).query( + f'SELECT COUNT(*) FROM `{PROJECT_ID}.{OUTPUT_DATASET}.{OUTPUT_TABLE}`').result()))[0] + +# Databricks — with the confirmed SQL warehouse; wait_timeout is at most '50s' +from databricks.sdk import WorkspaceClient +response = WorkspaceClient().statement_execution.execute_statement( + warehouse_id=DATABRICKS_WAREHOUSE_ID, wait_timeout='30s', + statement=f'SELECT COUNT(*) FROM {CATALOG}.{OUTPUT_SCHEMA}.{OUTPUT_TABLE}') +written = int(response.result.data_array[0][0]) + +# Snowflake — through the same connections.toml entry +import snowflake.connector +with snowflake.connector.connect(connection_name=SNOWFLAKE_CONNECTION) as conn: + written = conn.cursor().execute( + f'SELECT COUNT(*) FROM {OUTPUT_DATABASE}.{OUTPUT_SCHEMA}.{OUTPUT_TABLE}').fetchone()[0] + +assert written == prediction_result.num_rows, (written, prediction_result.num_rows) +``` + +- `OUTPUT_LOCATION` for files, S3 and GCS is the confirmed destination itself. +- A Databricks destination is passed to Kumo as `'__kumo__
'` and read back as + `..
`. With a cluster and no SQL warehouse, there is no read-back here: + say so in the artifact and in your reply. +- Report the destination in its own form, unchanged — `DB.SCHEMA.TABLE`, or the path. + +> **Not verified:** none of these read-backs, and no connector other than Snowflake, has been run +> end to end with this skill. Say so when you hand over an artifact for one of them. + +### 3.1 Static scoring population + +When Node 1d confirmed a population table (`kumoai-reference/references/kumo_finetuning_sdk.md` +§7.1), stage it in the **source's** system — where the entity table is read from — whatever the +destination. Each recipe below reads the entity table and writes the population inside that one +system; the destination may be in another system (`adapter.md` §4) and plays no part here. + +- **Files, S3, GCS source:** read the entity key — and, when the entity table has a time column, that + column too — from the source with `pyarrow.dataset`; name them `ENTITY1` and `TIMESTAMP`; write + one Parquet file inside the confirmed population path, creating the directory first for local + files; and build the `PredictionTable` from a connector rooted there. Decide from the entity's + `Table` as you built it: if it has a `time_column`, the population **must** carry it as + `TIMESTAMP`, **cast to a timestamp**. Kumo rejects a population without it, even for a static + query, whatever the §7.1 Snowflake example shows (`ValueError: ... missing TIMESTAMP`), and + reads a `DATE` anchor as null (`Column 'TIMESTAMP' is date32[day]`): a date column is cast. + + ```python + import os + import pyarrow as pa + import pyarrow.compute as pc + import pyarrow.dataset as ds + import pyarrow.parquet as papq + + keep = {ENTITY_KEY: 'ENTITY1'} + if ENTITY_TIME: # the entity Table's time_column, else None + keep[ENTITY_TIME] = 'TIMESTAMP' + population = ds.dataset(ENTITY_SOURCE, format=ENTITY_FORMAT).to_table(columns=list(keep)) + population = population.rename_columns([keep[c] for c in population.column_names]) + if 'TIMESTAMP' in population.column_names: # a DATE anchor reads as null + i = population.column_names.index('TIMESTAMP') + population = population.set_column(i, 'TIMESTAMP', + pc.cast(population['TIMESTAMP'], pa.timestamp('us'))) + if '://' not in POPULATION_PATH: + os.makedirs(POPULATION_PATH, exist_ok=True) # object stores need no directory + papq.write_table(population, POPULATION_PATH + 'population.parquet') + if POPULATION_PATH.startswith('s3://'): # the source's kind, in the §2 forms + population_connector = kumo.S3Connector(root_dir=POPULATION_PATH) + elif POPULATION_PATH.startswith('gs://'): + population_connector = kumo.GCSConnector(root_dir=POPULATION_PATH) + else: + population_connector = kumo.FileConnector(root_dir=POPULATION_PATH) + prediction_table = kumo.PredictionTable.from_source_table( + population_connector['population'], pq=pquery, snapshot=snapshot) + ``` + + `ENTITY_SOURCE` is the entity table's file or directory as preflight listed it, and + `ENTITY_FORMAT` its format as preflight found it, `'csv'` or `'parquet'`. The population is + written as Parquet either way. `POPULATION_PATH` is the confirmed path, ending in `/`, in the + source's file system or bucket. +- **Snowflake source:** the §7.1 SQL, run through + `snowflake.connector.connect(connection_name=SNOWFLAKE_CONNECTION)` as in the read-back above, + and the `PredictionTable` built from a `SnowflakeConnector` made as in §2 but scoped to the + population's database and schema. +- **Databricks source:** create the table through the source's SQL warehouse with the statement + API, wait for it, and build the `PredictionTable` from a `DatabricksConnector` scoped to the + population's schema. A source reached through a cluster has no SQL warehouse: say at Node 1d + that staging is not covered there, and offer the rows whose target is NULL instead. The same `TIMESTAMP` rule applies: project `CAST(
'` | the schema must be the connector's own; a bare table name fails; writes stage through the volume `.._kumo_staging`, created on first use | + +`output_paths` with a non-file connector raises `ValueError`. Read every destination back after +the write, as the host's `runtime.md` says (§9.1 for Snowflake). + --- ## 10. Complete verified example +On Snowflake. On any other connector, replace steps 1, 7 and 8 with the host `runtime.md`'s +connect step, the §9.2 destination and its read-back; nothing else changes. + Self-contained: `OUTPUT_DATABASE`, `OUTPUT_SCHEMA` and `OUTPUT_TABLE` are placeholders for the confirmed output destination (§12). @@ -700,20 +781,21 @@ targets a superseded build and needs rewriting against §10 — do not copy its | Code location | Source | |---|---| -| `TARGET = dict(warehouse=, database=, schema_name=)` | Data location from the intake contract; a lower-case name keeps its double quotes (§3) | -| `from_connection_toml(connection_name=)` | The user's `[connections.]` entry; ask | -| `SnowflakeIdentity(account=, username=)`, secret names | Account identifier, key-pair user, and the names of the attached key and passphrase secrets; ask only when neither factory applies, never for a secret's value | +| The connect step | The host's `runtime.md`, from the confirmed `source_scope` (§3, §3.4) | +| `TARGET = dict(warehouse=, database=, schema_name=)` (Snowflake) | Data location from the intake contract; a lower-case name keeps its double quotes (§3) | +| `from_connection_toml(connection_name=)` (Snowflake) | The user's `[connections.]` entry; ask | +| `SnowflakeIdentity(account=, username=)`, secret names (Snowflake) | Account identifier, key-pair user, and the names of the attached key and passphrase secrets; ask only when neither factory applies, never for a secret's value | | `configure(scratch_dir=)` | A local path; `/tmp/` is fine | -| `SnowflakeConnector(role=)` | Role from the intake contract | +| `SnowflakeConnector(role=)` (Snowflake) | Role from the intake contract | | `connector['TABLE']` names, column names | Exactly as `table_names()` / `.columns` return them (§3.2) | | Column selection, `stype` / `dtype` | Data structure from the intake contract | | `Edge(src_table=, fkey=, dst_table=)` | Relationships from the intake contract | | PQL `query=` | Generated per `kumoai-reference/references/pql_writing.md` | | `prediction_time=` | A known in-range **naive** `datetime`; **cannot be computed** | -| `num_classes_to_return=N` | Results per entity the user asked for, 1–500; defaults to `RANK TOP K` but may differ. Required for link prediction (§9) | -| `binary_classification_threshold=` | Required for binary classification | +| `predict(num_classes_to_return=N)` | Results per entity the user asked for, 1–500; defaults to `RANK TOP K` but may differ. Required for link prediction (§9) | +| `predict(binary_classification_threshold=)` | Required for binary classification; never on `PredictionOutputConfig`. The threshold restated at Node 1d: the user's, else `0.5 [default]` (`kumo-data-science/accept/intake_contract.md`) | | Population table (static task, rows that already have a target) | Confirmed at Node 1d (§7.1) | -| `PredictionOutputConfig(table_name=)` + output connector `database`/`schema_name` | Output destination; report back as `DB.SCHEMA.TABLE`, unchanged | +| `PredictionOutputConfig(...)` + output connector | Output destination in its connector's form (§9.2); report it back unchanged — `DB.SCHEMA.TABLE`, or the path | --- @@ -726,12 +808,14 @@ targets a superseded build and needs rewriting against §10 — do not copy its - [ ] `Stype` / `Dtype` imported from `kumoapi.typing`; `Column` / `Edge` from `kumo.engine`; `PredictionArtifactType` / `WriteMode` from `kumoapi.jobs`; `RunMode` from `kumoapi.model_plan`. -- [ ] Connection tries `from_spcs_env`, then `from_connection_toml`, then `SnowflakeIdentity` from - secrets (§3), with explicit `warehouse`, `database`, `schema_name`; `SPCS_TOKEN_PATH` - imported, never hardcoded. -- [ ] Lower-case or mixed-case `database` / `schema_name` passed with their double quotes (§3). +- [ ] The connect step is the host `runtime.md`'s, with no credential literal (§3, §3.4). +- [ ] Snowflake: connection tries `from_spcs_env`, then `from_connection_toml`, then + `SnowflakeIdentity` from secrets (§3), with explicit `warehouse`, `database`, + `schema_name`; `SPCS_TOKEN_PATH` imported, never hardcoded. +- [ ] Snowflake: lower-case or mixed-case `database` / `schema_name` passed with their double + quotes (§3). - [ ] Table and column names in the catalog's exact spelling (§3.2). -- [ ] `session = get_active_session()` once, in the first code cell; never again, no +- [ ] Snowflake: `session = get_active_session()` once, in the first code cell; never again, no `Session.builder` (§3.3). - [ ] `connector.table_names()` asserted before building tables. - [ ] Every foreign key corrected with `.with_column(col, stype=Stype.ID)`. @@ -741,14 +825,17 @@ targets a superseded build and needs rewriting against §10 — do not copy its - [ ] `graph.snapshot()` before generating any table. - [ ] **`trainer.fit(training_table, snapshot=snapshot)`** — `snapshot=` present. - [ ] **`trainer.predict(prediction_table, snapshot=snapshot, ...)`** — `snapshot=` present. +- [ ] `binary_classification_threshold` / `num_classes_to_return` on `predict`, never on + `PredictionOutputConfig` (§9). - [ ] `prediction_time` hardcoded and **naive** — no `tzinfo`, no `timezone.utc` — with the reason stated. - [ ] Static query: generated population only for rows whose target is NULL; otherwise the population table confirmed at Node 1d (§7.1). - [ ] No code cell writes anywhere unless a destination was confirmed; an example of how to add one later is markdown text, not a code cell. - [ ] Smoke-test sizing called out as such. -- [ ] Output table reported with its exact name — no `_predictions` suffix. -- [ ] Written table read back and row count compared to `prediction_result.num_rows`. +- [ ] Destination in its connector's form (§9.2), reported with its exact name — no + `_predictions` suffix. +- [ ] Written destination read back and row count compared to `prediction_result.num_rows`. - [ ] Success judged by blocking calls returning without raising — not by a status value. --- @@ -770,8 +857,8 @@ targets a superseded build and needs rewriting against §10 — do not copy its - `PredictionResult.export` against Snowflake — in the package, exercised nowhere. - Whether a snapshot can be reused across graphs or persisted between processes. - The written column schema for a ranking task. -- Behaviour of `FileConnector`, `S3Connector`, `GCSConnector`, `BigQueryConnector`, - `DatabricksConnector`. Only Snowflake is covered here. +- `FileConnector`, `S3Connector`, `GCSConnector`, `BigQueryConnector`, `DatabricksConnector`: + §3.4 and §9.2 are read from the package source; none has been run end to end with this skill. **Known absent** — do not reach for these: `SourceTable.head(...)`, `graph.infer_links()`, `graph.get_edge_stats()`, `graph.get_table_stats()`, `table.validate()`, diff --git a/agent-skills/skills/kumoai-reference/references/outputs.md b/agent-skills/skills/kumoai-reference/references/outputs.md index ac8b690b9..2cca7a586 100644 --- a/agent-skills/skills/kumoai-reference/references/outputs.md +++ b/agent-skills/skills/kumoai-reference/references/outputs.md @@ -74,12 +74,12 @@ vectors; inspect TYPE and identifiers before calling them customer/product embed ## Serialization boundaries -The public prediction path exports selected Parquet through Snowflake's file sink, with schema -inference and COPY. This differs from the separate in-memory DataFrame writer, which rejects +On Snowflake, the public prediction path exports selected Parquet through Snowflake's file sink, +with schema inference and COPY. This differs from the separate in-memory DataFrame writer, which rejects list/dict columns. Do not apply that rejection indiscriminately to prediction export, and do not send nested cohort JSON through the scalar DataFrame path unchanged. -For vectors or other nested fields, inspect the destination's actual Snowflake type and a +For vectors or other nested fields, inspect the destination's actual column type and a bounded read-back value; inference is not a guarantee of the consumer's expected representation. Use [stage retention](../../kumo-model-persistence/references/retention.md) to preserve the original Parquet or JSON when a destination cannot represent the required shape. Conversion/flattening diff --git a/agent-skills/skills/kumoai-reference/references/task_journeys.md b/agent-skills/skills/kumoai-reference/references/task_journeys.md index 37d49530e..5d888d736 100644 --- a/agent-skills/skills/kumoai-reference/references/task_journeys.md +++ b/agent-skills/skills/kumoai-reference/references/task_journeys.md @@ -103,7 +103,7 @@ scores as if they established all horizons. Column analysis is not supported for ## Finish each journey -Use [outputs](outputs.md) for requested Snowflake write-back and a separate embedding call. +Use [outputs](outputs.md) for requested write-back and a separate embedding call. Inspect selected Parquet schemas before interpreting columns; do not hardcode optional fields. Use [diagnostics](../../kumo-training-diagnostics/SKILL.md) for actual metrics and trial histories, and [explanations](../../kumo-model-explanations/SKILL.md) for available attribution/cohorts. diff --git a/agent-skills/skills/kumoai-reference/references/troubleshooting_signals.md b/agent-skills/skills/kumoai-reference/references/troubleshooting_signals.md index 1d9fce6e6..b497ce5c9 100644 --- a/agent-skills/skills/kumoai-reference/references/troubleshooting_signals.md +++ b/agent-skills/skills/kumoai-reference/references/troubleshooting_signals.md @@ -37,7 +37,7 @@ Inspect a table through `.definition` (`.pkey`, `.time_col`, `.end_time_col`) an ### Symptom: `connector.table("X")` raises `ValueError` **Cause:** The table name does not exist in the connector. -**Fix:** List what is actually there before constructing tables: `connector.table_names()`, or guard with `connector.has_table(name)`. Snowflake object names are typically uppercase (`connector.table("ORDERS")`). +**Fix:** List what is actually there before constructing tables: `connector.table_names()`, or guard with `connector.has_table(name)`. Snowflake object names, for example, are typically uppercase (`connector.table("ORDERS")`). ### Symptom: a column's `stype` is unset, or the query fails because a column has no semantic type **Cause:** `Table.from_source_table()` and the explicit `Table(...)` constructor do **not** fully populate `dtype`/`stype`. A partially specified table is legal until it is used in a predictive query. diff --git a/agent-skills/tests/test_package_skill.py b/agent-skills/tests/test_package_skill.py index ef89482d1..0ccb12a81 100644 --- a/agent-skills/tests/test_package_skill.py +++ b/agent-skills/tests/test_package_skill.py @@ -54,3 +54,32 @@ def test_broken_references_are_reported(tmp_path: Path) -> None: 'SKILL.md: link (accept/missing.md) does not resolve', 'SKILL.md: `accept/nope.md` does not resolve from the package root', ] + + +def test_generic_ships_only_its_own_host(tmp_path: Path) -> None: + pkg = package_skill.build('generic', tmp_path) + assert pkg == tmp_path / 'generic' / 'skills' / 'kumo-data-science' + assert [p.name for p in (pkg / 'hosts').iterdir()] == ['generic'] + leaks = [str(p.relative_to(pkg)) for p in pkg.rglob('*.md') + if 'snowflake-coco' in p.read_text() or 'ml_job.md' in p.read_text()] + assert leaks == [] + + +def test_core_names_no_host() -> None: + """The portable core reaches its host as `hosts//`, never by a host's own name.""" + hosts = sorted(p.name for p in (package_skill.SKILLS / 'hosts').iterdir() if p.is_dir()) + named = re.compile(r'hosts/(?:' + '|'.join(map(re.escape, hosts)) + r')\b') + hits = [f'{p.relative_to(package_skill.SKILLS)}: {m.group(0)}' + for p in sorted(package_skill.SKILLS.rglob('*.md')) + if p.relative_to(package_skill.SKILLS).parts[0] != 'hosts' + for m in named.finditer(p.read_text())] + assert hits == [] + + +@pytest.mark.parametrize('host', sorted(package_skill.HOSTS)) +def test_adapter_covers_what_the_core_defers(host: str) -> None: + """Preflight §0, the intake contract and batch inference defer these to the adapter.""" + adapter = next(rel for rel in package_skill.HOSTS[host]['hosts'] if rel.endswith('adapter.md')) + text = (package_skill.SKILLS / 'hosts' / adapter).read_text() + for section in ['Source scope', 'Access', 'Destinations']: + assert section in text, f'{adapter} does not cover {section}'