diff --git a/CHANGELOG.md b/CHANGELOG.md index 7409ef5a..fe7ffb81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ All notable changes to `tan` are documented here. Format follows ## [Unreleased] +### Added +- **`tan model` edge-AI lifecycle surface** (thin envelope wrappers over + `alp_cli model`, all emitting the stable `{command,ok,exitCode,project,data, + issues}` envelope): + - **`tan model check`** — offline static pre-flight fit/perf: per-SoM-backend + verdict (`fits` / `cpu-fallback` / `no-fit`) plus estimated SRAM, latency and + op-coverage, before you build. Single-model (` --sku`) or board-mode + (`--board board.yaml [--model NAME]`). + - **`tan model zoo`** / **`tan model add`** — browse curated model-zoo entries + (marked `runs_here` for the SoM via `--sku`/`--board`) and one-click append a + chosen entry to `board.yaml` `models:` (source fetched + SHA-256 verified). + - **`tan model prep`** — license-free INT8 quantize + fp32-vs-int8 accuracy + report (`.onnx` or `.tflite` input). + - **`tan model run`** (repurposed from the streaming placeholder to a wrappable + host reference run, `backend: cpu-host`) and **`tan model ab`** (A/B two + models on the same input). Host-reference latency, NOT target-SoM performance. + ## [0.3.0] — 2026-07-24 ### Added diff --git a/README.md b/README.md index 61d8d226..7404b71e 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,10 @@ executes it — it is the single executor and the user command surface for building, flashing, and inspecting Alp Lab E1M / E1M-X firmware. `build` / `run` / `size` / `image` / `flash` / `clean` / `renode` are native -Rust; only `migrate` / `lock` / `quality` still forward to `west alp-*`, and -`model` / `monitor` / `new-som` / `faultdecode` to the SDK `alp` CLI. Licensed +Rust; `migrate` / `lock` / `quality` still forward to `west alp-*`, and +`monitor` / `new-som` / `faultdecode` to the SDK `alp` CLI. `model` is no longer +a bare forwarder — it is a fuller subcommand family, a thin envelope wrapper over +the SDK's `alp model ` (see [Model](#model)). Licensed **Apache-2.0** (see [`LICENSE`](LICENSE); the SPDX identifier is also set in each `Cargo.toml` and source header). @@ -126,7 +128,8 @@ script. | **Configure & verify** | `validate` · `generate` · `diff` · `inspect` · `trace` · `doctor` · `debug-config` · `support-bundle` · `kconfig` | | **Build & run** (native) | `build` · `run` · `flash` · `image` · `size` · `clean` · `renode` | | **Environment** | `bootstrap` · `sdk` · `completion` | -| **Forwarders** | `migrate` · `lock` · `quality` → `west alp-*`; `model` · `monitor` · `new-som` · `faultdecode` → `python -m alp_cli` | +| **Model** | `model build` · `list` · `info` · `doctor` · `check` · `zoo` · `add` · `prep` · `run` · `ab` (envelope wrapper over `alp model` — see [Model](#model)) | +| **Forwarders** | `migrate` · `lock` · `quality` → `west alp-*`; `monitor` · `new-som` · `faultdecode` → `python -m alp_cli` | `tan --help` for flags. Global flags apply to every command: @@ -144,6 +147,29 @@ script. alp-sdk-vscode extension consumes. Text output is for humans and may change; the envelope is the API. +### Model + +`tan model ` mirrors the alp-sdk `alp model ` surface — a thin envelope +wrapper emitting `{command, ok, exitCode, project, data, issues}`. The +pre-existing subcommands compile the `board.yaml` `models:` list and inspect +toolchains; `check` / `zoo` / `add` / `prep` / `run` / `ab` are the +model-lifecycle add-ons. + +| Subcommand | What it does | +| --- | --- | +| `model build` / `list` / `info` / `doctor` | Compile `board.yaml` models: → `.alpmodel`; list; decode; report toolchains. | +| `model check --sku ` (or `--board board.yaml [--model NAME]`) `[--format human\|json]` | Static **pre-flight** fit/perf, offline, no toolchain. Per SoM-backend verdict `fits` / `cpu-fallback` / `no-fit`, plus est SRAM (vs the SoC arena budget), est latency, op-coverage %, unsupported ops. Labelled `source:static` (tier-1, biased **conservative** — never over-promises "fits"); verified on silicon later. | +| `model zoo [--sku \| --board board.yaml] [--format]` | Browse curated model-zoo entries (`metadata/model_zoo/.yaml`), each marked `runs_here` for the SoM via `validated_soms`. Link + fetch + layer, no weight redistribution. | +| `model add [--board board.yaml] [--name NAME] [--models-dir DIR]` | Fetch the source (URL sha256-verified, or bundled) and append `{name, source}` to `board.yaml` models:. Non-destructive (duplicate name errors). | +| `model prep --calibration [--out] [--per-channel] [--min-samples N]` | License-free INT8 quantize (onnxruntime QDQ) + fp32-vs-int8 accuracy report (top1 agreement %, mean cosine, max-abs-err, verdict `good` / `degraded` + guidance). `.tflite` is converted to ONNX first via tf2onnx. | +| `model run [--input FILE.npy] [--expected LABEL] [--runs N]` | **Host** reference run, backend `cpu-host`: functional + host-latency + accuracy. `peak_sram_kib` / `power_mj` are null on host. | +| `model ab [--input] [--runs]` | A/B two models on the same input (host reference): latency + size delta. | + +Honest caveats: `run` / `ab` are a **host reference, not target-SoM perf**; +`check` is a **conservative static pre-flight estimate**, verified on silicon +later; power + on-device measurement are **HW-gated** (they need the EVK +power-topology + Yocto NPU runtimes). + ## Where it sits (three repos, one executor) ``` diff --git a/crates/tan-cli/README.md b/crates/tan-cli/README.md index fc43d2f7..ad1be91e 100644 --- a/crates/tan-cli/README.md +++ b/crates/tan-cli/README.md @@ -14,5 +14,10 @@ Ported commands: `validate`, `generate`, `init`, `scaffold`, `examples`, Rust; only `migrate`/`lock`/`quality` still forward to `west alp-*`. `kconfig` wraps the SDK's `--emit kconfig` (needs a bootstrapped Zephyr workspace). -Plus `model`/`monitor`/`new-som`/`faultdecode` forwarders to the SDK `alp` -CLI (`python -m alp_cli `). +Plus `monitor`/`new-som`/`faultdecode` forwarders to the SDK `alp` CLI +(`python -m alp_cli `). `model` mirrors `alp model` +(`build`/`list`/`info`/`doctor`/`check`/`zoo`/`add`/`prep`/`run`/`ab`), +surfacing the JSON envelope (`{command,ok,exitCode,project,data,issues}`) +rather than being an opaque passthrough — `check` is a conservative offline +pre-flight (fit/perf estimate, verified on silicon later); `run`/`ab` are +host-reference runs, not target-SoM performance. diff --git a/crates/tan-cli/src/cli.rs b/crates/tan-cli/src/cli.rs index b48d6476..0da435a5 100644 --- a/crates/tan-cli/src/cli.rs +++ b/crates/tan-cli/src/cli.rs @@ -148,7 +148,7 @@ pub enum Command { /// Run the board.yaml quality checks (`west alp-quality`). Quality(WestForwardArgs), /// Compile + package board.yaml `models:` into `.alpmodel` (`alp model`). - Model(WestForwardArgs), + Model(ModelArgs), /// Open a serial console to the board (`alp monitor`). Monitor(WestForwardArgs), /// Scaffold a new SoM's metadata skeleton (`alp new-som`). @@ -320,6 +320,200 @@ pub struct WestForwardArgs { pub args: Vec, } +/// Args for `model`: a real clap subcommand group (not a trailing-var-arg +/// passthrough), so `--format`/other globals parse correctly regardless of +/// where they're placed on the command line — a passthrough would otherwise +/// swallow a global placed AFTER `model` (e.g. `tan model list --format +/// json`, the shape the vscode extension calls) into the forwarded argv +/// instead of parsing it. +#[derive(Debug, Args)] +pub struct ModelArgs { + #[command(subcommand)] + pub sub: ModelSub, +} + +/// The `alp_cli model` subcommands. All mirror `alp_cli`'s own option surface +/// (`--board`/`--out`/`--metadata-root`/…) so tan can wrap their `--format +/// json` payload in its envelope. +#[derive(Debug, Subcommand)] +pub enum ModelSub { + /// Compile board.yaml `models:` into `.alpmodel` packages. + Build(ModelBuildArgs), + /// List board.yaml `models:` + built `.alpmodel` status. + List(ModelListArgs), + /// Decode a built `.alpmodel`: targets, requires, coverage matrix. + Info(ModelInfoArgs), + /// Static pre-flight fit/perf check for a model on a SoM (offline, no toolchain). + Check(ModelCheckArgs), + /// Browse curated model-zoo entries (and which run on a SoM). + Zoo(ModelZooArgs), + /// Add a model-zoo entry to board.yaml (fetch source + append models:). + Add(ModelAddArgs), + /// License-free INT8 quantize + fp32-vs-int8 accuracy report. + Prep(ModelPrepArgs), + /// Report installed NPU compiler toolchains. + Doctor, + /// Host reference run of a model: deterministic (or supplied) input, + /// optional top-1 accuracy check, optional timed median-latency run. + Run(ModelRunArgs), + /// A/B two models on the same input. + Ab(ModelAbArgs), +} + +/// Args for `model build`. +#[derive(Debug, Args)] +pub struct ModelBuildArgs { + /// Path to board.yaml (default: `board.yaml`). + #[arg(long)] + pub board: Option, + /// Output directory for built `.alpmodel` packages (default: `build/models`). + #[arg(long)] + pub out: Option, + /// Path to the metadata/ root (default: the SDK's own `metadata/`). + #[arg(long = "metadata-root")] + pub metadata_root: Option, + /// Build only the named `models:` entry (default: build all declared models). + #[arg(long, value_name = "NAME")] + pub model: Option, +} + +/// Args for `model list`. +#[derive(Debug, Args)] +pub struct ModelListArgs { + /// Path to board.yaml (default: `board.yaml`). + #[arg(long)] + pub board: Option, + /// Build output directory (default: `build/models`). + #[arg(long)] + pub out: Option, +} + +/// Args for `model info`. +#[derive(Debug, Args)] +pub struct ModelInfoArgs { + /// Name of the built model to decode. + #[arg(value_name = "NAME")] + pub name: String, + /// Build output directory (default: `build/models`). + #[arg(long)] + pub out: Option, + /// board.yaml — enables the SoM coverage matrix. + #[arg(long)] + pub board: Option, + /// Path to the metadata/ root (default: the SDK's own `metadata/`). + #[arg(long = "metadata-root")] + pub metadata_root: Option, +} + +/// Args for `model check`. Two modes: a single `MODEL --sku`, or `--board` +/// (check every board.yaml `models:` entry against `som.sku`). +#[derive(Debug, Args)] +pub struct ModelCheckArgs { + /// Model file to check (`.tflite`). Mutually exclusive with `--board`. + #[arg(value_name = "MODEL")] + pub model: Option, + /// SoM SKU, e.g. `E1M-AEN801`. Required unless `--board` supplies it. + #[arg(long, value_name = "SKU")] + pub sku: Option, + /// Check every (or one, with `--model`) `models:` entry in this board.yaml. + #[arg(long, value_name = "PATH")] + pub board: Option, + /// With `--board`, check only the named `models:` entry. + #[arg(long = "model", value_name = "NAME")] + pub select: Option, + /// Path to the metadata/ root (default: the SDK's own `metadata/`). + #[arg(long = "metadata-root")] + pub metadata_root: Option, +} + +/// Args for `model zoo`. +#[derive(Debug, Args)] +pub struct ModelZooArgs { + /// Mark which entries run on this SoM (via validated_soms). + #[arg(long, value_name = "SKU")] + pub sku: Option, + /// Read the SoM SKU from this board.yaml (som.sku) to mark which entries run here. + #[arg(long, value_name = "PATH")] + pub board: Option, + /// Path to the metadata/ root (default: the SDK's own `metadata/`). + #[arg(long = "metadata-root")] + pub metadata_root: Option, +} + +/// Args for `model add`. +#[derive(Debug, Args)] +pub struct ModelAddArgs { + /// Zoo entry id to add. + #[arg(value_name = "ZOO_ID")] + pub zoo_id: String, + /// board.yaml to append to (default: `board.yaml`). + #[arg(long, value_name = "PATH")] + pub board: Option, + /// models: entry name (default: the zoo id). + #[arg(long, value_name = "NAME")] + pub name: Option, + /// Directory (relative to board.yaml) to cache the fetched model. + #[arg(long = "models-dir", value_name = "DIR")] + pub models_dir: Option, + /// Path to the metadata/ root (default: the SDK's own `metadata/`). + #[arg(long = "metadata-root")] + pub metadata_root: Option, +} + +/// Args for `model prep`. +#[derive(Debug, Args)] +pub struct ModelPrepArgs { + /// Raw model to quantize (`.onnx`). + #[arg(value_name = "RAW")] + pub raw: String, + /// Directory of .npy calibration samples matching the model input. + #[arg(long, value_name = "DIR")] + pub calibration: String, + /// Output INT8 .onnx (default: `.int8.onnx`). + #[arg(long, value_name = "FILE")] + pub out: Option, + /// Per-channel weight quantization (often recovers accuracy). + #[arg(long = "per-channel")] + pub per_channel: bool, + /// Minimum calibration samples. + #[arg(long = "min-samples", value_name = "N")] + pub min_samples: Option, +} + +/// Args for `model run` (host reference run). +#[derive(Debug, Args)] +pub struct ModelRunArgs { + /// Model to run (`.onnx`). + #[arg(value_name = "MODEL")] + pub model: String, + /// Input sample .npy (default: deterministic random matching the model input). + #[arg(long, value_name = "FILE")] + pub input: Option, + /// Expected class label (top-1 accuracy check). + #[arg(long, value_name = "LABEL")] + pub expected: Option, + /// Timed inference count (median latency). + #[arg(long, value_name = "N")] + pub runs: Option, +} + +/// Args for `model ab` (A/B two models on the same input). +#[derive(Debug, Args)] +pub struct ModelAbArgs { + /// First model (`.onnx`). + #[arg(value_name = "MODEL_A")] + pub model_a: String, + /// Second model (`.onnx`). + #[arg(value_name = "MODEL_B")] + pub model_b: String, + /// Shared input sample .npy (default: deterministic random matching model_a). + #[arg(long, value_name = "FILE")] + pub input: Option, + /// Timed inference count. + #[arg(long, value_name = "N")] + pub runs: Option, +} + /// Args for `run`: build the project, then run it. Thin orchestrator over the /// native `build` + `flash` commands — it reuses their engines, never /// re-derives them. The host-vs-hardware target is read from what the build diff --git a/crates/tan-cli/src/commands/mod.rs b/crates/tan-cli/src/commands/mod.rs index 1d8fc325..a4c15d6c 100644 --- a/crates/tan-cli/src/commands/mod.rs +++ b/crates/tan-cli/src/commands/mod.rs @@ -44,6 +44,9 @@ pub mod inspect; /// `tan kconfig` — board-scoped Kconfig symbol menu for one core (the vscode /// `prj.conf` LSP's live feed); wraps the SDK's `--emit kconfig`. pub mod kconfig; +/// `tan model` — captures + wraps `alp_cli model +/// --format json` in tan's envelope (`model run` still streams via `sdk_cli`). +pub mod model; /// `tan pinmux` — the E1M pinmux capability table (E1M pad → silicon function) for a SoM family. pub mod pinmux; /// `tan presets` — list SDK presets (SKUs/SoMs) + built-in catalogue defaults. diff --git a/crates/tan-cli/src/commands/model.rs b/crates/tan-cli/src/commands/model.rs new file mode 100644 index 00000000..39be4f2b --- /dev/null +++ b/crates/tan-cli/src/commands/model.rs @@ -0,0 +1,689 @@ +// SPDX-License-Identifier: Apache-2.0 +//! `tan model` — forwards to `alp_cli model +//! `. +//! `alp_cli model --format json` already emits its own JSON payload; +//! tan doesn't re-derive it, it wraps it in the stable +//! `{command,ok,exitCode,project,data,issues}` envelope so a JSON consumer +//! (the vscode extension) gets one contract for every `tan` subcommand. +//! +//! `model` is a real clap subcommand group (`ModelSub`), not a +//! trailing-var-arg passthrough — a passthrough parses every token after +//! `model` as opaque, so a global flag placed there (e.g. `tan model list +//! --format json`, the shape the vscode extension calls) got swallowed into +//! the forwarded argv instead of being parsed as `GlobalArgs::format`. +//! +//! Every sub (including `run`, the host reference run, and `ab`, the two-model +//! comparison) now emits a `--format json` payload tan can wrap — see +//! `is_wrappable`. In text mode tan still streams live through `sdk_cli::run` +//! rather than capturing + re-printing. + +use std::process::Command; + +use super::{CommandRun, sdk_cli}; +use crate::cli::{GlobalArgs, ModelSub}; +use crate::envelope::{Envelope, Issue, Project}; +use crate::exit::ExitCode; +use crate::util::resolve_cli_project_context; + +/// Append `--flag value` to `argv` iff `value` is `Some`. +fn push_opt(argv: &mut Vec, flag: &str, value: &Option) { + if let Some(v) = value { + argv.push(flag.to_string()); + argv.push(v.clone()); + } +} + +/// Map a structured [`ModelSub`] to the `alp_cli model` argv tail (everything +/// after `model` itself) — e.g. `Build { board: Some("b.yaml"), .. }` → +/// `["build", "--board", "b.yaml"]` — including only the options that are +/// `Some`. +pub fn sub_argv(sub: &ModelSub) -> Vec { + match sub { + ModelSub::Build(a) => { + let mut argv = vec!["build".to_string()]; + push_opt(&mut argv, "--board", &a.board); + push_opt(&mut argv, "--out", &a.out); + push_opt(&mut argv, "--metadata-root", &a.metadata_root); + push_opt(&mut argv, "--model", &a.model); + argv + } + ModelSub::List(a) => { + let mut argv = vec!["list".to_string()]; + push_opt(&mut argv, "--board", &a.board); + push_opt(&mut argv, "--out", &a.out); + argv + } + ModelSub::Info(a) => { + let mut argv = vec!["info".to_string(), a.name.clone()]; + push_opt(&mut argv, "--out", &a.out); + push_opt(&mut argv, "--board", &a.board); + push_opt(&mut argv, "--metadata-root", &a.metadata_root); + argv + } + ModelSub::Check(a) => { + let mut argv = vec!["check".to_string()]; + if let Some(m) = &a.model { + argv.push(m.clone()); + } + push_opt(&mut argv, "--sku", &a.sku); + push_opt(&mut argv, "--board", &a.board); + push_opt(&mut argv, "--model", &a.select); + push_opt(&mut argv, "--metadata-root", &a.metadata_root); + argv + } + ModelSub::Zoo(a) => { + let mut argv = vec!["zoo".to_string()]; + push_opt(&mut argv, "--sku", &a.sku); + push_opt(&mut argv, "--board", &a.board); + push_opt(&mut argv, "--metadata-root", &a.metadata_root); + argv + } + ModelSub::Add(a) => { + let mut argv = vec!["add".to_string(), a.zoo_id.clone()]; + push_opt(&mut argv, "--board", &a.board); + push_opt(&mut argv, "--name", &a.name); + push_opt(&mut argv, "--models-dir", &a.models_dir); + push_opt(&mut argv, "--metadata-root", &a.metadata_root); + argv + } + ModelSub::Prep(a) => { + let mut argv = vec!["prep".to_string(), a.raw.clone()]; + argv.push("--calibration".to_string()); + argv.push(a.calibration.clone()); + push_opt(&mut argv, "--out", &a.out); + if a.per_channel { + argv.push("--per-channel".to_string()); + } + if let Some(n) = a.min_samples { + argv.push("--min-samples".to_string()); + argv.push(n.to_string()); + } + argv + } + ModelSub::Doctor => vec!["doctor".to_string()], + ModelSub::Run(a) => { + let mut argv = vec!["run".to_string(), a.model.clone()]; + push_opt(&mut argv, "--input", &a.input); + if let Some(e) = a.expected { + argv.push("--expected".to_string()); + argv.push(e.to_string()); + } + if let Some(n) = a.runs { + argv.push("--runs".to_string()); + argv.push(n.to_string()); + } + argv + } + ModelSub::Ab(a) => { + let mut argv = vec!["ab".to_string(), a.model_a.clone(), a.model_b.clone()]; + push_opt(&mut argv, "--input", &a.input); + if let Some(n) = a.runs { + argv.push("--runs".to_string()); + argv.push(n.to_string()); + } + argv + } + } +} + +/// Every `model` sub now forwards a `--format json` command whose payload tan +/// wraps in its envelope (the former streaming `Run` passthrough was repurposed +/// to the wrappable host reference run). +pub fn is_wrappable(_sub: &ModelSub) -> bool { + true +} + +/// Build the full `alp_cli` argv: `-m alp_cli model `, appending +/// `--format json` when `json` is set. The structured sub's own args never +/// contain `--format` (it's the global, not a per-sub option), so unlike the +/// old passthrough there's no dedup to do. +pub fn model_argv(sub: &ModelSub, json: bool) -> Vec { + let mut argv = vec!["-m".to_string(), "alp_cli".to_string(), "model".to_string()]; + argv.extend(sub_argv(sub)); + if json { + argv.push("--format".to_string()); + argv.push("json".to_string()); + } + argv +} + +/// Map the spawned child's exit code to tan's stable `ExitCode`: `Some(0)` is +/// success, any other code (or a spawn that produced none) is a runtime +/// failure — `model` has no validation/write/doctor exit class of its own. +pub fn map_model_exit(child_code: Option) -> ExitCode { + match child_code { + Some(0) => ExitCode::Success, + _ => ExitCode::RuntimeFailure, + } +} + +/// Wrap a captured `alp_cli model` run in tan's envelope, returning the JSON +/// document alongside the EFFECTIVE exit code (which the caller must also use +/// for `CommandRun.exit` — they must agree, see `envelope.rs`'s `ok` == `exit +/// == 0` invariant). `stdout` is parsed as JSON for `data`; a non-`Success` +/// `exit` adds one `model.failed` issue carrying the trimmed `stderr` (or a +/// generic line when stderr is empty) — checked first so it sorts before a +/// `bad-payload` issue when both fire (e.g. a failed run with empty stdout). +/// +/// An unparseable `stdout` adds a `model.bad-payload` issue and `data` becomes +/// `null`; if the child otherwise reported success, the effective exit is +/// downgraded to `InternalFailure` so `ok` stays reliable for a JSON consumer +/// — a child that exits 0 but prints garbage is tan's problem to flag, not a +/// silent `ok:true`. +pub fn wrap_model_json( + stdout: &str, + stderr: &str, + exit: ExitCode, + project: Project, +) -> (String, ExitCode) { + let mut issues = Vec::new(); + + if exit != ExitCode::Success { + let message = if stderr.trim().is_empty() { + "alp_cli model failed with no diagnostic output.".to_string() + } else { + stderr.trim().to_string() + }; + issues.push(Issue { + code: "model.failed".to_string(), + severity: "error".to_string(), + message, + }); + } + + let mut bad_payload = false; + let data = match serde_json::from_str::(stdout) { + Ok(v) => v, + Err(e) => { + bad_payload = true; + issues.push(Issue { + code: "model.bad-payload".to_string(), + severity: "error".to_string(), + message: format!("could not parse alp_cli model output as JSON: {e}"), + }); + serde_json::Value::Null + } + }; + + let effective_exit = if bad_payload && exit == ExitCode::Success { + ExitCode::InternalFailure + } else { + exit + }; + + let doc = Envelope::new("model", project, data, issues, effective_exit.code()).to_json(); + (doc, effective_exit) +} + +/// Best-effort `Project` for the envelope: reuses `resolve_cli_project_context` +/// (the same resolution `validate`/`diff`/`doctor` use). `build`/`list`/`info` +/// are board-scoped so this is the useful case; `doctor` isn't, but a +/// best-effort (possibly non-existent) `board_yaml` path is harmless there — +/// `Project` only carries a path, it never asserts the file exists. +fn project_from(g: &GlobalArgs) -> Project { + let context = resolve_cli_project_context(g); + Project { + root: context.workspace_root, + board_yaml: context.board_yaml_path, + } +} + +/// `tan model ` entry point. Text mode streams live through +/// `sdk_cli::run` — the existing forwarder, unchanged. Under `--format json` +/// (every sub is wrappable) tan instead resolves the SDK/python once via +/// `sdk_cli::prepare_alp_cli` (shared, not re-derived) and spawns the child +/// CAPTURED so its `--format json` payload can be wrapped in tan's envelope. +pub fn run(g: &GlobalArgs, sub: &ModelSub) -> CommandRun { + if !g.is_json() || !is_wrappable(sub) { + return sdk_cli::run(g, "model", &sub_argv(sub)); + } + + let spawn = match sdk_cli::prepare_alp_cli(g, "model") { + Ok(spawn) => spawn, + Err(guard_failure) => return guard_failure, + }; + + let project = project_from(g); + let argv = model_argv(sub, true); + let output = Command::new(&spawn.python) + .args(&argv) + .current_dir(&spawn.workspace_root) + .env("ALP_SDK_ROOT", &spawn.sdk_root) + .env("PYTHONPATH", &spawn.pythonpath) + .output(); + + match output { + Ok(out) => { + let exit = map_model_exit(out.status.code()); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + let (json, effective_exit) = wrap_model_json(&stdout, &stderr, exit, project); + CommandRun { + exit: effective_exit, + text: Vec::new(), + json: Some(json), + } + } + Err(e) => { + let message = sdk_cli::launch_error(&spawn.python, &e); + let issues = vec![Issue { + code: "model.failed".to_string(), + severity: "error".to_string(), + message, + }]; + let json = Envelope::new( + "model", + project, + serde_json::Value::Null, + issues, + ExitCode::RuntimeFailure.code(), + ) + .to_json(); + CommandRun { + exit: ExitCode::RuntimeFailure, + text: Vec::new(), + json: Some(json), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::{ + ModelAbArgs, ModelAddArgs, ModelBuildArgs, ModelCheckArgs, ModelInfoArgs, ModelListArgs, + ModelPrepArgs, ModelRunArgs, ModelZooArgs, + }; + + #[test] + fn sub_argv_maps_build_including_only_set_options() { + let sub = ModelSub::Build(ModelBuildArgs { + board: Some("b.yaml".to_string()), + out: Some("o".to_string()), + metadata_root: None, + model: None, + }); + assert_eq!( + sub_argv(&sub), + vec!["build", "--board", "b.yaml", "--out", "o"] + ); + } + + #[test] + fn sub_argv_forwards_the_model_selector() { + // B1 regression: the vscode panel builds a single model via `tan model + // build --model ` — the alp_cli argv must carry `--model NAME`. + let sub = ModelSub::Build(ModelBuildArgs { + board: None, + out: None, + metadata_root: None, + model: Some("demo".to_string()), + }); + let argv = sub_argv(&sub); + assert_eq!(argv, vec!["build", "--model", "demo"]); + } + + #[test] + fn sub_argv_maps_list_info_doctor() { + assert_eq!( + sub_argv(&ModelSub::List(ModelListArgs { + board: None, + out: Some("build/models".to_string()), + })), + vec!["list", "--out", "build/models"] + ); + assert_eq!( + sub_argv(&ModelSub::Info(ModelInfoArgs { + name: "demo".to_string(), + out: None, + board: Some("b.yaml".to_string()), + metadata_root: None, + })), + vec!["info", "demo", "--board", "b.yaml"] + ); + assert_eq!(sub_argv(&ModelSub::Doctor), vec!["doctor"]); + } + + #[test] + fn sub_argv_maps_run_with_expected_and_runs() { + let sub = ModelSub::Run(ModelRunArgs { + model: "m.onnx".to_string(), + input: None, + expected: Some(5), + runs: Some(3), + }); + assert_eq!( + sub_argv(&sub), + vec!["run", "m.onnx", "--expected", "5", "--runs", "3"] + ); + assert!(is_wrappable(&sub), "run is now wrappable"); + } + + #[test] + fn sub_argv_maps_ab_two_models() { + let sub = ModelSub::Ab(ModelAbArgs { + model_a: "a.onnx".to_string(), + model_b: "b.onnx".to_string(), + input: Some("in.npy".to_string()), + runs: None, + }); + assert_eq!( + sub_argv(&sub), + vec!["ab", "a.onnx", "b.onnx", "--input", "in.npy"] + ); + } + + #[test] + fn sub_argv_maps_check_with_required_sku() { + let sub = ModelSub::Check(ModelCheckArgs { + model: Some("m.tflite".to_string()), + sku: Some("E1M-AEN801".to_string()), + board: None, + select: None, + metadata_root: None, + }); + assert_eq!( + sub_argv(&sub), + vec!["check", "m.tflite", "--sku", "E1M-AEN801"] + ); + } + + #[test] + fn model_argv_appends_format_json_for_check() { + let sub = ModelSub::Check(ModelCheckArgs { + model: Some("m.tflite".to_string()), + sku: Some("E1M-AEN801".to_string()), + board: None, + select: None, + metadata_root: Some("meta".to_string()), + }); + let argv = model_argv(&sub, true); + // -m alp_cli model check m.tflite --sku E1M-AEN801 --metadata-root meta --format json + assert!(argv.ends_with(&["--format".to_string(), "json".to_string()])); + assert!(argv.contains(&"check".to_string())); + assert!(argv.contains(&"--metadata-root".to_string())); + assert!(is_wrappable(&sub), "check must be wrappable"); + } + + #[test] + fn sub_argv_maps_board_mode_with_selector() { + let sub = ModelSub::Check(ModelCheckArgs { + model: None, + sku: None, + board: Some("board.yaml".to_string()), + select: Some("tiny".to_string()), + metadata_root: None, + }); + assert_eq!( + sub_argv(&sub), + vec!["check", "--board", "board.yaml", "--model", "tiny"] + ); + } + + #[test] + fn sub_argv_maps_zoo_with_sku() { + let sub = ModelSub::Zoo(ModelZooArgs { + sku: Some("E1M-AEN801".to_string()), + board: None, + metadata_root: None, + }); + assert_eq!(sub_argv(&sub), vec!["zoo", "--sku", "E1M-AEN801"]); + } + + #[test] + fn sub_argv_maps_zoo_with_board() { + let sub = ModelSub::Zoo(ModelZooArgs { + sku: None, + board: Some("board.yaml".to_string()), + metadata_root: None, + }); + assert_eq!(sub_argv(&sub), vec!["zoo", "--board", "board.yaml"]); + } + + #[test] + fn sub_argv_maps_add_with_id_and_opts() { + let sub = ModelSub::Add(ModelAddArgs { + zoo_id: "example-tiny".to_string(), + board: Some("board.yaml".to_string()), + name: None, + models_dir: None, + metadata_root: None, + }); + assert_eq!( + sub_argv(&sub), + vec!["add", "example-tiny", "--board", "board.yaml"] + ); + assert!(is_wrappable(&sub), "add must be wrappable"); + } + + #[test] + fn sub_argv_maps_prep_required_calibration() { + let sub = ModelSub::Prep(ModelPrepArgs { + raw: "m.onnx".to_string(), + calibration: "cal".to_string(), + out: None, + per_channel: false, + min_samples: None, + }); + assert_eq!( + sub_argv(&sub), + vec!["prep", "m.onnx", "--calibration", "cal"] + ); + } + + #[test] + fn sub_argv_maps_prep_flags_and_min_samples() { + let sub = ModelSub::Prep(ModelPrepArgs { + raw: "m.onnx".to_string(), + calibration: "cal".to_string(), + out: Some("o.onnx".to_string()), + per_channel: true, + min_samples: Some(16), + }); + let argv = sub_argv(&sub); + assert!(argv.contains(&"--per-channel".to_string())); + assert_eq!( + argv.windows(2) + .find(|w| w[0] == "--min-samples") + .map(|w| &w[1]), + Some(&"16".to_string()) + ); + assert!(is_wrappable(&sub), "prep must be wrappable"); + } + + #[test] + fn is_wrappable_is_true_for_every_sub() { + assert!(is_wrappable(&ModelSub::Doctor)); + assert!(is_wrappable(&ModelSub::List(ModelListArgs { + board: None, + out: None, + }))); + assert!(is_wrappable(&ModelSub::Run(ModelRunArgs { + model: "m.onnx".to_string(), + input: None, + expected: None, + runs: None, + }))); + } + + #[test] + fn model_argv_appends_format_json_for_wrappable_subs() { + let sub = ModelSub::Build(ModelBuildArgs { + board: Some("b.yaml".to_string()), + out: None, + metadata_root: None, + model: None, + }); + assert_eq!( + model_argv(&sub, true), + vec![ + "-m", "alp_cli", "model", "build", "--board", "b.yaml", "--format", "json" + ] + ); + // text mode: no --format appended + assert_eq!( + model_argv(&sub, false), + vec!["-m", "alp_cli", "model", "build", "--board", "b.yaml"] + ); + } + + #[test] + fn map_model_exit_maps_success_and_failure() { + assert_eq!(map_model_exit(Some(0)), ExitCode::Success); + assert_eq!(map_model_exit(Some(1)), ExitCode::RuntimeFailure); + assert_eq!(map_model_exit(None), ExitCode::RuntimeFailure); + } + + #[test] + fn wrap_model_json_passes_payload_through_as_data() { + let payload = r#"{"models":[{"name":"demo","targets":[]}]}"#; + let (doc, effective_exit) = wrap_model_json( + payload, + "", + ExitCode::Success, + Project { + root: None, + board_yaml: None, + }, + ); + assert_eq!(effective_exit, ExitCode::Success); + let v: serde_json::Value = serde_json::from_str(&doc).unwrap(); + assert_eq!(v["command"], "model"); + assert_eq!(v["ok"], true); + assert_eq!(v["exitCode"], 0); + assert_eq!(v["data"]["models"][0]["name"], "demo"); + assert_eq!(v["issues"].as_array().unwrap().len(), 0); + } + + #[test] + fn wrap_model_json_reports_failure_and_bad_payload() { + // non-success exit + unparseable (empty) stdout → BOTH issues fire, + // model.failed first so a JSON consumer sees the primary cause before + // the secondary "couldn't even parse the output" complaint. + let (doc, effective_exit) = wrap_model_json( + "", + "no blob compiled", + ExitCode::RuntimeFailure, + Project { + root: None, + board_yaml: None, + }, + ); + assert_eq!(effective_exit, ExitCode::RuntimeFailure); + let v: serde_json::Value = serde_json::from_str(&doc).unwrap(); + assert_eq!(v["ok"], false); + assert_eq!(v["exitCode"], 1); + assert_eq!(v["issues"].as_array().unwrap().len(), 2); + assert_eq!(v["issues"][0]["code"], "model.failed"); + assert!( + v["issues"][0]["message"] + .as_str() + .unwrap() + .contains("no blob compiled") + ); + assert_eq!(v["issues"][1]["code"], "model.bad-payload"); + + // unparseable stdout on a child-reported "success" exit → downgraded to + // InternalFailure (exit 5) so `ok` stays reliable for a JSON consumer; + // a 0-exit child that printed garbage is not a success tan can vouch for. + let (doc2, effective_exit2) = wrap_model_json( + "not json", + "", + ExitCode::Success, + Project { + root: None, + board_yaml: None, + }, + ); + assert_eq!(effective_exit2, ExitCode::InternalFailure); + let v2: serde_json::Value = serde_json::from_str(&doc2).unwrap(); + assert_eq!(v2["ok"], false); + assert_eq!(v2["exitCode"], 5); + assert_eq!(v2["issues"][0]["code"], "model.bad-payload"); + assert!(v2["data"].is_null()); + } + + fn json_global() -> GlobalArgs { + GlobalArgs { + project: None, + board_yaml: None, + sdk_root: None, + target: None, + all: false, + format: crate::cli::Format::Json, + verbose: false, + quiet: false, + no_color: false, + non_interactive: false, + ci: false, + } + } + + fn text_global() -> GlobalArgs { + GlobalArgs { + format: crate::cli::Format::Text, + ..json_global() + } + } + + /// A `--sdk-root` guaranteed not to resolve, regardless of this machine's + /// ambient checkouts — routes `sdk_cli::prepare_alp_cli`'s guard to fail + /// deterministically (no live python/alp_cli spawn) whichever code path + /// reaches it, so these tests don't depend on (or accidentally spawn + /// against) a real alp-sdk sibling checkout. + const NO_SDK_ROOT: &str = "/definitely/does/not/exist/alp-sdk"; + + #[test] + fn text_mode_delegates_to_streaming_forwarder() { + // text mode must NOT capture/wrap: json is None (streamed), regardless + // of sub — mirrors sdk_cli::run's own streaming CommandRun shape. Even + // `doctor` (wrappable) must not produce json here; the guard failure + // routed through unconditionally by the unresolved sdk_root proves it + // (`fail()` only emits json when `g.is_json()`). + let g = GlobalArgs { + sdk_root: Some(NO_SDK_ROOT.to_string()), + ..text_global() + }; + let run = run(&g, &ModelSub::Doctor); + assert!(run.json.is_none()); + } + + #[test] + fn json_wrappable_sub_with_unresolved_sdk_root_yields_a_guard_failure_envelope() { + // Exercises prepare_alp_cli's failure branch (SDK root guard) without a + // real python spawn: --sdk-root pointing at a nonexistent path under + // --format json for a wrappable sub must yield an ok:false envelope, + // not attempt to spawn anything. + let g = GlobalArgs { + sdk_root: Some(NO_SDK_ROOT.to_string()), + ..json_global() + }; + let run = run(&g, &ModelSub::Doctor); + assert_eq!(run.exit, ExitCode::ValidationFailure); + let doc = run.json.expect("guard failure must emit a json envelope"); + let v: serde_json::Value = serde_json::from_str(&doc).unwrap(); + assert_eq!(v["ok"], false); + assert_eq!(v["exitCode"], 2); + assert_eq!(v["issues"][0]["code"], "model.failed"); + } + + /// Regression for the bug this commit fixes: a global placed AFTER `model` + /// (the vscode extension's exact call shape, `model doctor --format + /// json`) must parse into `GlobalArgs::format`, not get swallowed into a + /// passthrough — proven by asserting clap itself resolves it, independent + /// of any hand-rolled forwarding logic. + #[test] + fn global_after_model_subcommand_parses_as_a_global_not_passthrough() { + use clap::Parser; + let cli = crate::cli::Cli::try_parse_from(["tan", "model", "doctor", "--format", "json"]) + .expect("global after `model doctor` must parse"); + assert!(cli.global.is_json()); + assert!(matches!( + cli.command, + crate::cli::Command::Model(crate::cli::ModelArgs { + sub: ModelSub::Doctor + }) + )); + } +} diff --git a/crates/tan-cli/src/commands/sdk_cli.rs b/crates/tan-cli/src/commands/sdk_cli.rs index ae6ddf0b..6a6ce8f6 100644 --- a/crates/tan-cli/src/commands/sdk_cli.rs +++ b/crates/tan-cli/src/commands/sdk_cli.rs @@ -13,7 +13,7 @@ //! child's exit class. use std::ffi::{OsStr, OsString}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; use serde::Serialize; @@ -23,6 +23,17 @@ use crate::cli::GlobalArgs; use crate::envelope::{Envelope, Issue, Project}; use crate::exit::ExitCode; +/// Resolved inputs for spawning `python -m alp_cli …` against an SDK +/// checkout — everything [`prepare_alp_cli`] figures out once so both the +/// streaming forwarders ([`run`]) and `model`'s captured-output path +/// (`model::run`) share a single resolution instead of each re-deriving it. +pub(crate) struct AlpCliSpawn { + pub python: String, + pub sdk_root: PathBuf, + pub pythonpath: OsString, + pub workspace_root: PathBuf, +} + /// Envelope `data` payload for a failed forward — just the verb, since a /// captured-output payload is impossible for these interactive passthroughs /// (see the module doc). @@ -76,30 +87,32 @@ fn build_pythonpath( std::env::join_paths(entries) } -/// Run an `alp_cli` subcommand by spawning `python -m alp_cli ` -/// against the resolved SDK checkout, streaming its stdio through. `subcommand` -/// is the bare verb (`model`/`monitor`/`new-som`/`faultdecode`). -pub fn run(g: &GlobalArgs, subcommand: &str, passthrough: &[String]) -> CommandRun { +/// Resolve the SDK root, python interpreter, and `PYTHONPATH` needed to spawn +/// `python -m alp_cli …` — the guard chain (SDK root, python-too-old, +/// PYTHONPATH build) shared by both [`run`]'s streaming spawn and `model`'s +/// captured-output spawn. `subcommand` names the forward for the failing +/// envelope's issue code/`data` (`fail`'s `.failed`) — the only +/// per-caller bit of an otherwise identical resolution. +pub(crate) fn prepare_alp_cli(g: &GlobalArgs, subcommand: &str) -> Result { let workspace_root = crate::util::cli_workspace_root(g); let Some(sdk_root) = crate::util::resolve_sdk_root(g, &workspace_root) else { - return fail( + return Err(fail( g, subcommand, ExitCode::ValidationFailure, "alp-sdk root is unresolved. Use --sdk-root, pin one with \ `tan sdk switch `, or place the project near an alp-sdk checkout.", - ); + )); }; - let python = default_python_binary(); + let python = default_python_binary().to_string(); // Guard: the interpreter must be new enough to run the SDK scripts // (`@dataclass(slots=True)`, Python 3.10+) — parity with `generate`/`build`. - if let Some(message) = crate::util::python_too_old(python) { - return fail(g, subcommand, ExitCode::RuntimeFailure, &message); + if let Some(message) = crate::util::python_too_old(&python) { + return Err(fail(g, subcommand, ExitCode::RuntimeFailure, &message)); } - let argv = build_argv(subcommand, passthrough, g.is_json()); // Previously `.env("PYTHONPATH", sdk_root.join("scripts"))` unconditionally // — an outright overwrite ("ponytail: overwrite is fine — alp_cli needs // nothing else on PYTHONPATH", which was wrong) that silently dropped a @@ -112,21 +125,40 @@ pub fn run(g: &GlobalArgs, subcommand: &str, passthrough: &[String]) -> CommandR ) { Ok(p) => p, Err(e) => { - return fail( + return Err(fail( g, subcommand, ExitCode::RuntimeFailure, &format!("failed to build PYTHONPATH for alp_cli: {e}"), - ); + )); } }; - let mut cmd = Command::new(python); + + Ok(AlpCliSpawn { + python, + sdk_root, + pythonpath, + workspace_root, + }) +} + +/// Run an `alp_cli` subcommand by spawning `python -m alp_cli ` +/// against the resolved SDK checkout, streaming its stdio through. `subcommand` +/// is the bare verb (`model`/`monitor`/`new-som`/`faultdecode`). +pub fn run(g: &GlobalArgs, subcommand: &str, passthrough: &[String]) -> CommandRun { + let spawn = match prepare_alp_cli(g, subcommand) { + Ok(spawn) => spawn, + Err(guard_failure) => return guard_failure, + }; + + let argv = build_argv(subcommand, passthrough, g.is_json()); + let mut cmd = Command::new(&spawn.python); cmd.args(&argv) - .current_dir(&workspace_root) - .env("ALP_SDK_ROOT", &sdk_root) + .current_dir(&spawn.workspace_root) + .env("ALP_SDK_ROOT", &spawn.sdk_root) // alp_cli's absolute imports (`from alp_cli import …`) resolve from // /scripts; mirror `_alp_common.env_with_sdk()`. - .env("PYTHONPATH", &pythonpath); + .env("PYTHONPATH", &spawn.pythonpath); // Inherit stdio (the std default) and stream live — these are interactive / // passthrough tools, so capturing their output would break them. @@ -149,7 +181,7 @@ pub fn run(g: &GlobalArgs, subcommand: &str, passthrough: &[String]) -> CommandR g, subcommand, ExitCode::RuntimeFailure, - &launch_error(python, &e), + &launch_error(&spawn.python, &e), ), } } @@ -191,8 +223,10 @@ fn fail(g: &GlobalArgs, subcommand: &str, exit: ExitCode, message: &str) -> Comm } /// Map a Python launch I/O error to a user-facing message — special-casing -/// `NotFound` with an install hint. -fn launch_error(python: &str, e: &std::io::Error) -> String { +/// `NotFound` with an install hint. `pub(crate)` so `model::run`'s own +/// captured spawn (a python launch failure, not the child's own exit) reuses +/// the identical message instead of re-deriving it. +pub(crate) fn launch_error(python: &str, e: &std::io::Error) -> String { if e.kind() == std::io::ErrorKind::NotFound { format!("`{python}` not found on PATH — install Python 3.10+ and ensure it is on PATH.") } else { diff --git a/crates/tan-cli/src/main.rs b/crates/tan-cli/src/main.rs index 01cdf226..0f081bae 100644 --- a/crates/tan-cli/src/main.rs +++ b/crates/tan-cli/src/main.rs @@ -62,7 +62,7 @@ fn main() { Command::Migrate(args) => commands::build::run(&global, "migrate", &args.args), Command::Lock(args) => commands::build::run(&global, "lock", &args.args), Command::Quality(args) => commands::build::run(&global, "quality", &args.args), - Command::Model(args) => commands::sdk_cli::run(&global, "model", &args.args), + Command::Model(args) => commands::model::run(&global, &args.sub), Command::Monitor(args) => commands::sdk_cli::run(&global, "monitor", &args.args), Command::NewSom(args) => commands::sdk_cli::run(&global, "new-som", &args.args), Command::Faultdecode(args) => commands::sdk_cli::run(&global, "faultdecode", &args.args), diff --git a/docs/plans/2026-07-24-tan-model-check.md b/docs/plans/2026-07-24-tan-model-check.md new file mode 100644 index 00000000..71b47173 --- /dev/null +++ b/docs/plans/2026-07-24-tan-model-check.md @@ -0,0 +1,143 @@ +# `tan model check` — Envelope Wrapper for the Static Analyzer (Slice 1b) + +**Goal:** Add `tan model check --sku ` — a thin envelope wrapper over alp-sdk's `alp model check` (slice 1a), mirroring `tan model list`/`info`. + +**Architecture:** `ModelSub::Check(ModelCheckArgs)` — a new wrappable subcommand. `sub_argv` maps it to the `alp_cli model check --sku [--metadata-root …]` argv tail; `model_argv` appends `--format json`; the existing `wrap_model_json` wraps the `{model,sku,backends,suggestion}` payload into tan's `{command,ok,exitCode,project,data,issues}` envelope. On failure, `wrap_model_json` already captures the child's stderr into a `model.failed` issue — no new error handling needed. + +**Tech Stack:** Rust (edition 2024, rustc ≥1.85), clap 4.6. The four cargo gates: `cargo fmt --all --check`, `cargo clippy --all-targets -- -D warnings`, `cargo build --all-targets`, `cargo test`. + +## Global Constraints + +- **Thin passthrough only** — no fit/analysis logic in tan; it forwards to `alp model check` and wraps the envelope. Mirror the existing `Info` arm exactly. +- **`Check` is wrappable** (emits `--format json`) — `is_wrappable` already returns true for everything except `Run`, so NO change there. +- **No 1a change** — the alp-sdk `check_cmd` (stderr `error: …` on failure) surfaces through `wrap_model_json`'s `model.failed` issue as-is. +- **Branch:** `feat/tan-model-check`, stacked on `feat/tan-model-envelope` (#47). PR targets `dev` (retarget when #47 lands). Do NOT merge. NO Claude/AI attribution. +- **This is tan-cli** — work→dev→main via PR, `enforce_admins=true`; never push to `dev`/`main` directly. + +--- + +## Task 1: `ModelSub::Check` variant + `ModelCheckArgs` + +**Files:** +- Modify: `crates/tan-cli/src/cli.rs` + +**Interfaces:** +- Produces: `ModelSub::Check(ModelCheckArgs)` where `ModelCheckArgs { model: String (positional), sku: String (--sku, required), metadata_root: Option (--metadata-root) }`. + +- [ ] **Step 1: Add the enum variant** + +In `crates/tan-cli/src/cli.rs`, in `enum ModelSub` (after `Info(ModelInfoArgs)`, before `Doctor`), add: + +```rust + /// Static pre-flight fit/perf check for a model on a SoM (offline, no toolchain). + Check(ModelCheckArgs), +``` + +- [ ] **Step 2: Add the args struct** + +Immediately after the `ModelInfoArgs` struct definition, add (mirrors `ModelInfoArgs`, but `--sku` required + a positional model path): + +```rust +/// Args for `model check`. +#[derive(Debug, Args)] +pub struct ModelCheckArgs { + /// Model file to check (`.tflite`). + #[arg(value_name = "MODEL")] + pub model: String, + /// SoM SKU, e.g. `E1M-AEN801`. + #[arg(long, value_name = "SKU")] + pub sku: String, + /// Path to the metadata/ root (default: the SDK's own `metadata/`). + #[arg(long = "metadata-root")] + pub metadata_root: Option, +} +``` + +- [ ] **Step 3: Build to confirm it compiles (exhaustiveness will fail in model.rs — expected)** + +Run: `cd && cargo build --all-targets 2>&1 | head -20` +Expected: a non-exhaustive-match error in `commands/model.rs` `sub_argv` (the new variant isn't handled yet) — that is Task 2. + +--- + +## Task 2: `sub_argv` arm + test + +**Files:** +- Modify: `crates/tan-cli/src/commands/model.rs` + +**Interfaces:** +- Consumes: `ModelSub::Check` + `ModelCheckArgs` (Task 1). +- Produces: `sub_argv(Check)` → `["check", , "--sku", , ("--metadata-root", )?]`. + +- [ ] **Step 1: Add the `sub_argv` arm** + +In `crates/tan-cli/src/commands/model.rs` `fn sub_argv`, add an arm after the `Info` arm (before `Doctor`): + +```rust + ModelSub::Check(a) => { + let mut argv = vec!["check".to_string(), a.model.clone()]; + argv.push("--sku".to_string()); + argv.push(a.sku.clone()); + push_opt(&mut argv, "--metadata-root", &a.metadata_root); + argv + } +``` + +- [ ] **Step 2: Add the import to the test module + a mapping test** + +In the `#[cfg(test)] mod tests` block, extend the `use crate::cli::{…}` import to include `ModelCheckArgs`, then add: + +```rust + #[test] + fn sub_argv_maps_check_with_required_sku() { + let sub = ModelSub::Check(ModelCheckArgs { + model: "m.tflite".to_string(), + sku: "E1M-AEN801".to_string(), + metadata_root: None, + }); + assert_eq!(sub_argv(&sub), vec!["check", "m.tflite", "--sku", "E1M-AEN801"]); + } + + #[test] + fn model_argv_appends_format_json_for_check() { + let sub = ModelSub::Check(ModelCheckArgs { + model: "m.tflite".to_string(), + sku: "E1M-AEN801".to_string(), + metadata_root: Some("meta".to_string()), + }); + let argv = model_argv(&sub, true); + // -m alp_cli model check m.tflite --sku E1M-AEN801 --metadata-root meta --format json + assert!(argv.ends_with(&["--format".to_string(), "json".to_string()])); + assert!(argv.contains(&"check".to_string())); + assert!(argv.contains(&"--metadata-root".to_string())); + assert!(is_wrappable(&sub), "check must be wrappable"); + } +``` + +(Confirm `model_argv`/`is_wrappable` are already imported in the test module — the existing `sub_argv_*`/`model_argv_*` tests use them. If a symbol isn't in scope, add it to the test module's `use super::*;`/`use crate::…` line, matching the existing tests.) + +- [ ] **Step 3: The four cargo gates (all must pass)** + +Run in ``: +```bash +cargo fmt --all --check +cargo clippy --all-targets -- -D warnings +cargo build --all-targets +cargo test +``` +Expected: all green; the two new tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/tan-cli/src/cli.rs crates/tan-cli/src/commands/model.rs +git commit -m "feat(model): add 'tan model check' envelope wrapper" +``` + +--- + +## Self-Review + +- **Spec coverage:** roadmap §3.5 "tan-cli: `tan model check` — envelope-wraps it (same pattern as `tan model {build,list,info,doctor}`)" → Tasks 1+2. ✓ +- **Placeholder scan:** all code complete; the only judgment call is confirming the test-module `use` line (Task 2 Step 2) — explicitly instructed to match existing tests. +- **Type consistency:** `ModelCheckArgs` fields (`model`/`sku`/`metadata_root`) identical across cli.rs, sub_argv, and both tests; `sub_argv(Check)` output shape matches what `model_argv` + `wrap_model_json` consume (they're generic over the argv/payload — no per-command coupling).