diff --git a/README.md b/README.md index f113e0c..fd32717 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,10 @@ app-specific (often closed) glue. Start with the [MANIFESTO](MANIFESTO.md) (the short front door), then the spec. -> **Project status — normative draft.** Spec v0.3, ABI v0.1. Every section carries a maturity +> **Project status — normative draft.** Spec v0.4, ABI v0.1. Every section carries a maturity > tag (*working* / *drafted* / *direction*). Expect the drafted parts to change through ADRs. -## The spec (v0.3) +## The spec (v0.4) Rendered documentation: **https://skainet-developers.github.io/SKaiNET-cartridge/** @@ -31,6 +31,13 @@ as Asciidoc: [`cartridge-abi/include/cartridge_abi.h`](cartridge-abi/include/cartridge_abi.h)) - [`task-profiles.adoc`](docs/modules/ROOT/pages/task-profiles.adoc) — per-task consumer contracts (`asr/v1`, `yolo/v1`) and the typed facade +- [`blueprints.adoc`](docs/modules/ROOT/pages/blueprints.adoc) — **new in v0.4**: the open, + weight-free recipe a cartridge is built from, and *materialization* — how a blueprint plus a + profile becomes a signed cartridge. Guides: + [tutorial](docs/modules/ROOT/pages/tutorials/build-cartridge-from-blueprint.adoc), + [private materialization](docs/modules/ROOT/pages/how-to/materialize-privately.adoc), + [explanation](docs/modules/ROOT/pages/explanation/blueprint-vs-cartridge.adoc); + design record: [SKEEP-007](docs/modules/ROOT/pages/skeep/007-cartridge-blueprints.adoc) - [`threat-model.adoc`](docs/modules/ROOT/pages/threat-model.adoc) — who verifies what, where - [`roadmap.adoc`](docs/modules/ROOT/pages/roadmap.adoc) — phases 2–5 - [`adr/`](docs/modules/ROOT/pages/adr/index.adoc) — decision log diff --git a/docs/antora.yml b/docs/antora.yml index 3c3599f..55b4698 100644 --- a/docs/antora.yml +++ b/docs/antora.yml @@ -6,5 +6,5 @@ nav: asciidoc: attributes: - spec_version: "0.3" + spec_version: "0.4" abi_version: "0.1" diff --git a/docs/modules/ROOT/examples/cartridge-blueprint.schema.json b/docs/modules/ROOT/examples/cartridge-blueprint.schema.json new file mode 100644 index 0000000..e9f7c80 --- /dev/null +++ b/docs/modules/ROOT/examples/cartridge-blueprint.schema.json @@ -0,0 +1,524 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://skainet-developers.github.io/SKaiNET-cartridge/schema/cartridge-blueprint.schema.json", + "title": "Cartridge blueprint", + "description": "Everything needed to BUILD a cartridge, and nothing that has to be downloaded to do so: identity, a descriptor template, pinned sources, a pinned toolchain, targets, and the ordered steps that turn sources into the artifacts of a pack_dir (blueprints.adoc, SKEEP-007, ADR-015). A blueprint carries no weights, no compiled model and no runtime binary.", + "type": "object", + "additionalProperties": false, + "required": [ + "spec_version", + "kind", + "id", + "version", + "license", + "descriptor_template", + "sources", + "toolchain", + "targets", + "steps", + "outputs" + ], + "properties": { + "spec_version": { + "const": "0.4", + "description": "The cartridge-spec version this blueprint conforms to." + }, + "kind": { + "const": "blueprint" + }, + "id": { + "type": "string", + "description": "Stable identity, OQ3 naming without the target: --. The id of a materialized cartridge is `-` unless the target sets `cartridge_id`.", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "examples": [ + "nlu-functiongemma-270m-iree", + "asr-moonshine-v2-streaming-iree" + ] + }, + "version": { + "type": "string", + "description": "Version of the blueprint (recipe + code), semver. Independent of the version of any cartridge materialized from it.", + "pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$" + }, + "description": { + "type": "string" + }, + "license": { + "type": "string", + "description": "License of the blueprint's OWN content — code, recipe, docs. SPDX. This is NOT the effective license of a materialized cartridge, which is computed at materialization from the licenses of the sources and inputs actually used (ADR-004).", + "examples": [ + "MIT", + "Apache-2.0" + ] + }, + "descriptor_template": { + "type": "object", + "description": "A capability descriptor with the materialization-resolved fields left out. Template + resolved fields MUST validate against cartridge-descriptor.schema.json. The fields named in `propertyNames.not` are resolved by the materializer and MUST NOT appear here: identity and version come from the profile, `target` from the chosen target, `license` is computed, and `performance` / `quality` are MEASURED on the materialized artifacts — a blueprint cannot know them.", + "required": [ + "family", + "modality", + "task", + "io", + "execution_mode" + ], + "propertyNames": { + "not": { + "enum": [ + "spec_version", + "id", + "version", + "license", + "target", + "performance", + "quality" + ] + } + } + }, + "sources": { + "type": "array", + "minItems": 1, + "description": "What materialization downloads. Every source is pinned by an immutable revision AND by the sha256 of every file taken from it.", + "items": { + "$ref": "#/$defs/source" + } + }, + "toolchain": { + "type": "object", + "description": "Every tool a step names, pinned to an exact version. Keys are the names steps refer to in `tool`.", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/$defs/tool" + } + }, + "targets": { + "type": "array", + "minItems": 1, + "description": "The targets this blueprint knows how to materialize for. A cartridge is built for exactly one of them.", + "items": { + "$ref": "#/$defs/target" + } + }, + "flavors": { + "type": "array", + "description": "Optional. Variants one materialization can bundle as descriptor `flavors` (ADR-014) — typically a language: which sources belong to the variant and which descriptor fields it overrides.", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "sources" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" + }, + "description": { + "type": "string" + }, + "sources": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + }, + "description": "Names of the `sources` this flavor needs." + }, + "descriptor_overrides": { + "type": "object", + "description": "Descriptor fields this flavor replaces in full (ADR-014: no deep merge), e.g. `attributes` and `model`." + } + } + } + }, + "inputs": { + "type": "array", + "description": "Materialization inputs: things the blueprint needs but deliberately does not contain, supplied by the materialization profile — a tool catalog, a calibration set, a vocabulary. Declaring them keeps host- or organization-specific content out of the blueprint.", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "role", + "required" + ], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" + }, + "description": { + "type": "string" + }, + "role": { + "$ref": "#/$defs/role", + "description": "The manifest artifact role the input takes in the materialized cartridge." + }, + "required": { + "type": "boolean" + }, + "schema": { + "type": "string", + "description": "Path (relative to the blueprint) or URL of a JSON Schema the supplied input MUST validate against." + } + } + } + }, + "steps": { + "type": "array", + "minItems": 1, + "description": "The recipe, in execution order. A step reads sources, inputs or earlier steps' products (`uses`) and yields named products (`produces`).", + "items": { + "$ref": "#/$defs/step" + } + }, + "outputs": { + "type": "array", + "minItems": 1, + "description": "How step products become the manifest artifacts of the materialized pack_dir (ADR-012).", + "items": { + "$ref": "#/$defs/output" + } + }, + "verification": { + "type": "array", + "description": "Checks a materialization MUST pass before it is packed and signed. Source digests are always verified and need no entry here.", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "kind" + ], + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "parity", + "golden", + "task-metric", + "conformance" + ], + "description": "'parity' = compiled/quantized output vs. float reference within `tolerance` (index.adoc, Developing, 5a); 'golden' = fixed input → expected output shipped with the blueprint; 'task-metric' = task quality on a pinned evaluation set (5c); 'conformance' = ctg-conform (OQ5)." + }, + "description": { + "type": "string" + }, + "tolerance": { + "type": "number", + "minimum": 0 + }, + "on_device": { + "type": "boolean", + "description": "true when the check needs the real target hardware." + } + } + } + }, + "reference_measurements": { + "type": "array", + "description": "Optional. What the blueprint's authors measured, per target, stated as an EXPECTATION for whoever materializes. Never copied into a descriptor: a materialized cartridge reports only what was measured on its own artifacts.", + "items": { + "type": "object", + "required": [ + "target", + "measured_on" + ], + "properties": { + "target": { + "type": "string" + }, + "flavor": { + "type": "string" + }, + "measured_on": { + "type": "string" + }, + "performance": { + "type": "object" + }, + "quality": { + "type": "object" + } + } + } + } + }, + "$defs": { + "digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "role": { + "type": "string", + "enum": [ + "runtime", + "model", + "weights", + "tokenizer", + "calibration", + "descriptor", + "other" + ], + "description": "Manifest artifact roles (cartridge-manifest.schema.json)." + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "role", + "uri", + "revision", + "files", + "license", + "redistribution" + ], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "description": "What steps and outputs refer to." + }, + "role": { + "$ref": "#/$defs/role" + }, + "uri": { + "type": "string", + "description": "Where the publisher serves it. `hf:///` for a model hub repository, otherwise an https URL. A profile MAY substitute a mirror; the digests below do not change.", + "examples": [ + "hf://google/functiongemma-270m-it", + "https://example.org/models/tiny-streaming-en.tar.gz" + ] + }, + "revision": { + "type": "string", + "description": "Immutable revision at the publisher: a commit id for repositories, a release identifier otherwise. A branch name or `main` is not a revision." + }, + "files": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "digest" + ], + "properties": { + "path": { + "type": "string" + }, + "digest": { + "$ref": "#/$defs/digest" + }, + "size": { + "type": "integer", + "minimum": 0 + } + } + } + }, + "license": { + "type": "string", + "description": "License of the source as published — SPDX where possible, otherwise a marker (`community-restricted`, `proprietary`) or the license's name.", + "examples": [ + "MIT", + "Apache-2.0", + "community-restricted", + "Gemma Terms of Use" + ] + }, + "license_url": { + "type": "string" + }, + "redistribution": { + "type": "string", + "enum": [ + "allowed", + "restricted", + "acceptance-required" + ], + "description": "'allowed' = the license permits redistributing the file or works derived from it; 'restricted' = it does not, or only under conditions a materializer must check (revenue cap, display duty, field of use); 'acceptance-required' = the publisher makes access conditional on accepting terms — materialization MUST refuse to fetch until the profile records that acceptance." + } + } + }, + "tool": { + "type": "object", + "additionalProperties": false, + "required": [ + "version" + ], + "properties": { + "version": { + "type": "string", + "description": "Exact version. Ranges are not pins." + }, + "coordinates": { + "type": "string", + "description": "Where the tool comes from when it is a library or plugin.", + "examples": [ + "sk.ainet.transformers:skainet-transformers-bom" + ] + }, + "image": { + "type": "string", + "description": "Container image when the tool runs in one; SHOULD include a digest." + }, + "description": { + "type": "string" + } + } + }, + "target": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "target" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "examples": [ + "vulkan-arm64", + "cpu-arm64", + "cpu-x86-64" + ] + }, + "cartridge_id": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "description": "Id of the cartridge materialized for this target, when `-` is not wanted." + }, + "target": { + "type": "object", + "description": "Same shape as the descriptor's `target`.", + "additionalProperties": false, + "required": [ + "hardware", + "abi" + ], + "properties": { + "hardware": { + "type": "string" + }, + "abi": { + "type": "string" + }, + "accelerator": { + "type": "string" + } + } + }, + "requirements": { + "type": "object", + "description": "Descriptor `requirements` that hold for every cartridge built for this target." + }, + "params": { + "type": "object", + "description": "Target-specific parameters steps may read (compiler backend, GPU architecture, dtype)." + } + } + }, + "step": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "kind", + "tool", + "produces" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" + }, + "kind": { + "type": "string", + "enum": [ + "fetch", + "convert", + "quantize", + "export", + "compile", + "build-runtime", + "measure", + "pack", + "sign" + ], + "description": "'fetch' downloads and digest-verifies sources; 'convert' changes format or layout without changing numerics beyond dtype casts; 'quantize' changes numeric precision; 'export' turns a model into a compiler input (e.g. StableHLO); 'compile' turns that into a target artifact; 'build-runtime' builds the native runtime library; 'measure' produces performance/quality on the target; 'pack' assembles the pack_dir; 'sign' signs the manifest." + }, + "description": { + "type": "string" + }, + "tool": { + "type": "string", + "description": "A key of `toolchain`." + }, + "uses": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Names of sources, inputs, or products of earlier steps." + }, + "produces": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "per_target": { + "type": "boolean", + "description": "true when the step's products differ per target (compile, build-runtime, measure)." + }, + "params": { + "type": "object" + } + } + }, + "output": { + "type": "object", + "additionalProperties": false, + "required": [ + "from", + "role", + "path", + "derived_from" + ], + "properties": { + "from": { + "type": "string", + "description": "A step product." + }, + "role": { + "$ref": "#/$defs/role" + }, + "path": { + "type": "string", + "description": "Path inside the pack_dir; by convention under `artifacts//` (ADR-012).", + "pattern": "^[^/].*" + }, + "derived_from": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Names of the sources and inputs this artifact descends from — what the manifest's `derived_from` and the license computation are built from. Empty only for artifacts built purely from the blueprint's own code." + }, + "flavor": { + "type": "string", + "description": "Set when the artifact exists only for one flavor." + } + } + } + } +} diff --git a/docs/modules/ROOT/examples/cartridge-descriptor.schema.json b/docs/modules/ROOT/examples/cartridge-descriptor.schema.json index f03cd66..27e73c3 100644 --- a/docs/modules/ROOT/examples/cartridge-descriptor.schema.json +++ b/docs/modules/ROOT/examples/cartridge-descriptor.schema.json @@ -8,8 +8,8 @@ "required": ["spec_version", "id", "version", "family", "modality", "task", "io", "target", "performance", "execution_mode", "license"], "properties": { "spec_version": { - "const": "0.3", - "description": "The cartridge-spec version this descriptor conforms to." + "enum": ["0.3", "0.4"], + "description": "The cartridge-spec version this descriptor conforms to. The descriptor shape is unchanged between 0.3 and 0.4 (v0.4 adds blueprints and optional manifest provenance), so both are valid." }, "execution_mode": { "type": "string", diff --git a/docs/modules/ROOT/examples/cartridge-manifest.schema.json b/docs/modules/ROOT/examples/cartridge-manifest.schema.json index 242cbc9..efe09ea 100644 --- a/docs/modules/ROOT/examples/cartridge-manifest.schema.json +++ b/docs/modules/ROOT/examples/cartridge-manifest.schema.json @@ -100,6 +100,39 @@ "description": "Present only when value is worse than baseline: the recorded justification for shipping anyway. Never ship a degradation silently." } } + }, + "blueprint": { + "description": "v0.4 (SKEEP-007, ADR-015): set when this cartridge was materialized from a blueprint — which recipe produced these bytes. `digest` is the sha256 of the blueprint file and is what makes `version` mean exactly one recipe.", + "type": "object", + "additionalProperties": false, + "required": ["id", "version", "digest"], + "properties": { + "id": { "type": "string" }, + "version": { "type": "string" }, + "digest": { "$ref": "#/$defs/digest" }, + "source": { + "type": "object", + "additionalProperties": false, + "properties": { + "repo": { "type": "string" }, + "commit": { "type": "string" }, + "coordinates": { "type": "string", "examples": ["sk.ainet.cartridge:nlu-functiongemma-270m-iree:0.1.0"] } + } + } + } + }, + "materialization": { + "description": "v0.4: how the blueprint was executed. `profile_digest` pins the materialization profile without publishing it (a private profile stays private; its digest still makes the build auditable to whoever holds it). `toolchain` records the versions ACTUALLY used, which equal the blueprint's pins unless a substitution is being declared.", + "type": "object", + "additionalProperties": false, + "required": ["target", "toolchain"], + "properties": { + "profile_digest": { "$ref": "#/$defs/digest" }, + "target": { "type": "string", "description": "Id of the blueprint target that was materialized." }, + "flavors": { "type": "array", "items": { "type": "string" } }, + "toolchain": { "type": "object", "additionalProperties": { "type": "string" }, "examples": [{ "skainet": "0.56.0", "skainet-transformers": "0.56.0", "iree-tools": "3.11.0" }] }, + "materializer": { "type": "string", "examples": ["sk.ainet.cartridge.blueprint@0.1.0"] } + } } } }, diff --git a/docs/modules/ROOT/examples/materialization-profile.schema.json b/docs/modules/ROOT/examples/materialization-profile.schema.json new file mode 100644 index 0000000..49e1387 --- /dev/null +++ b/docs/modules/ROOT/examples/materialization-profile.schema.json @@ -0,0 +1,229 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://skainet-developers.github.io/SKaiNET-cartridge/schema/materialization-profile.schema.json", + "title": "Materialization profile", + "description": "What one party supplies to turn a blueprint into a cartridge: which blueprint, which target and flavors, the produced cartridge's version, license acceptances, optional source mirrors, the declared inputs, the measurements taken on the materialized artifacts, and who signs. Everything organization-specific lives here — never in the blueprint (blueprints.adoc).", + "type": "object", + "additionalProperties": false, + "required": [ + "spec_version", + "kind", + "blueprint", + "target", + "cartridge" + ], + "properties": { + "spec_version": { + "const": "0.4" + }, + "kind": { + "const": "materialization-profile" + }, + "blueprint": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "version" + ], + "properties": { + "id": { + "type": "string" + }, + "version": { + "type": "string", + "description": "Exact blueprint version. Ranges are not pins." + }, + "digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$", + "description": "sha256 of the blueprint file. SHOULD be set: it is what makes `blueprint.version` mean one recipe." + }, + "source": { + "type": "object", + "additionalProperties": false, + "properties": { + "repo": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "coordinates": { + "type": "string", + "description": "When the blueprint was consumed as a published artifact.", + "examples": [ + "sk.ainet.cartridge:nlu-functiongemma-270m-iree:0.1.0" + ] + } + } + } + } + }, + "target": { + "type": "string", + "description": "Id of one of the blueprint's `targets`." + }, + "flavors": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Ids of the blueprint `flavors` to bundle. Omitted = the base only." + }, + "cartridge": { + "type": "object", + "additionalProperties": false, + "required": [ + "version" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "description": "Overrides the id the blueprint would give the materialized cartridge." + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$", + "description": "Version of the cartridge being produced — owned by whoever materializes, independent of the blueprint's version." + } + } + }, + "license_acceptance": { + "type": "array", + "description": "One entry per source whose `redistribution` is `acceptance-required`. Without it the fetch step MUST refuse. A record of a decision a person made — never a default.", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "license", + "accepted_by", + "accepted_at" + ], + "properties": { + "source": { + "type": "string" + }, + "license": { + "type": "string" + }, + "terms_url": { + "type": "string" + }, + "accepted_by": { + "type": "string", + "description": "The legal entity or person accepting." + }, + "accepted_at": { + "type": "string", + "format": "date" + } + } + } + }, + "mirrors": { + "type": "object", + "description": "Source name → alternative URI (an internal artifact store, a cache). A mirror changes where bytes come from, never which bytes: every digest in the blueprint still applies.", + "additionalProperties": { + "type": "string" + } + }, + "inputs": { + "type": "object", + "description": "Blueprint input name → the supplied file.", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "license" + ], + "properties": { + "path": { + "type": "string" + }, + "digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "license": { + "type": "string", + "description": "License of the supplied input; enters the effective-license computation like any source.", + "examples": [ + "MIT", + "proprietary" + ] + } + } + } + }, + "measurements": { + "type": "object", + "description": "Descriptor `performance` and `quality` (and per-flavor values), MEASURED on the artifacts this materialization produced, on the stated device. Omit when the blueprint's `measure` step produces them. Copying a blueprint's `reference_measurements` here is a spec violation.", + "additionalProperties": false, + "properties": { + "performance": { + "type": "object" + }, + "quality": { + "type": "object" + }, + "flavors": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "performance": { + "type": "object" + }, + "quality": { + "type": "object" + } + } + } + } + } + }, + "descriptor_overrides": { + "type": "object", + "description": "Descriptor fields only the materializing party can state, e.g. `requirements.driver` for a specific device fleet or `attributes` that follow from a supplied input.", + "propertyNames": { + "not": { + "enum": [ + "spec_version", + "family", + "modality", + "task", + "io", + "license", + "performance", + "quality" + ] + } + } + }, + "signing": { + "type": "object", + "additionalProperties": false, + "required": [ + "keyid", + "signer" + ], + "properties": { + "keyid": { + "type": "string" + }, + "signer": { + "type": "string", + "description": "Who vouches for this cartridge: the materializing party, not the blueprint's authors." + }, + "key_ref": { + "type": "string", + "description": "Reference the materializer resolves to the private key (a CI secret name, a key store alias). Never the key." + } + } + } + } +} diff --git a/docs/modules/ROOT/examples/nlu-functiongemma-270m-iree.blueprint.json b/docs/modules/ROOT/examples/nlu-functiongemma-270m-iree.blueprint.json new file mode 100644 index 0000000..8a6629b --- /dev/null +++ b/docs/modules/ROOT/examples/nlu-functiongemma-270m-iree.blueprint.json @@ -0,0 +1,293 @@ +{ + "spec_version": "0.4", + "kind": "blueprint", + "id": "nlu-functiongemma-270m-iree", + "version": "0.1.0", + "description": "Function-calling NLU: FunctionGemma 270M exported to StableHLO and compiled with IREE. ILLUSTRATIVE worked example — revisions and digests are placeholders, not the real upstream values.", + "license": "MIT", + "descriptor_template": { + "execution_mode": "native", + "family": "functiongemma-270m", + "modality": "text", + "task": "nlu", + "model": { + "name": "functiongemma-270m-it", + "params": 270000000, + "upstream": { + "source": "google/functiongemma-270m-it", + "license": "Gemma Terms of Use" + } + }, + "io": { + "mode": "batch", + "input": { + "dtype": "utf8-text" + }, + "output": { + "dtype": "utf8-json", + "output_schema": { + "type": "object", + "required": [ + "name", + "arguments" + ], + "properties": { + "name": { + "type": "string" + }, + "arguments": { + "type": "object" + } + } + } + } + }, + "attributes": { + "languages": [ + "en" + ], + "streaming": false + } + }, + "sources": [ + { + "name": "weights", + "role": "weights", + "uri": "hf://google/functiongemma-270m-it", + "revision": "0000000000000000000000000000000000000000", + "files": [ + { + "path": "model.safetensors", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000001", + "size": 536000000 + }, + { + "path": "config.json", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000002" + } + ], + "license": "Gemma Terms of Use", + "license_url": "https://ai.google.dev/gemma/terms", + "redistribution": "acceptance-required" + }, + { + "name": "tokenizer", + "role": "tokenizer", + "uri": "hf://google/functiongemma-270m-it", + "revision": "0000000000000000000000000000000000000000", + "files": [ + { + "path": "tokenizer.json", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000003" + } + ], + "license": "Gemma Terms of Use", + "license_url": "https://ai.google.dev/gemma/terms", + "redistribution": "acceptance-required" + } + ], + "toolchain": { + "skainet": { + "version": "0.56.0", + "coordinates": "sk.ainet:skainet-bom" + }, + "skainet-transformers": { + "version": "0.56.0", + "coordinates": "sk.ainet.transformers:skainet-transformers-bom" + }, + "iree-tools": { + "version": "3.11.0", + "description": "IREE tools (StableHLO -> IREE): iree-compile, iree-convert-parameters, runtime build" + }, + "blueprint-plugin": { + "version": "0.1.0", + "coordinates": "sk.ainet.cartridge.blueprint" + } + }, + "targets": [ + { + "id": "vulkan-arm64", + "target": { + "hardware": "IREE-arm64-v8a", + "abi": "arm64-v8a", + "accelerator": "Vulkan GPU" + }, + "requirements": { + "needs_accelerator": true + }, + "params": { + "iree_backend": "vulkan-spirv", + "dtype": "bf16" + } + }, + { + "id": "cpu-arm64", + "target": { + "hardware": "IREE-arm64-v8a", + "abi": "arm64-v8a" + }, + "params": { + "iree_backend": "llvm-cpu", + "dtype": "f32" + } + } + ], + "inputs": [ + { + "name": "tool-catalog", + "description": "The functions the model may call. Host- or organization-specific by nature, so it is an input, never part of the blueprint.", + "role": "other", + "required": true, + "schema": "schema/tool-catalog.schema.json" + } + ], + "steps": [ + { + "id": "fetch", + "kind": "fetch", + "tool": "blueprint-plugin", + "uses": [ + "weights", + "tokenizer" + ], + "produces": [ + "weights-files", + "tokenizer-files" + ] + }, + { + "id": "export", + "kind": "export", + "tool": "skainet-transformers", + "uses": [ + "weights-files" + ], + "produces": [ + "graphs-mlir", + "parameters-safetensors" + ], + "description": "Kotlin: trace prefill / prefill-with-past / with-past graphs to StableHLO; weights stay external.", + "per_target": true, + "params": { + "graphs": [ + "prefill_at", + "prefill_with_past", + "with_past" + ] + } + }, + { + "id": "compile", + "kind": "compile", + "tool": "iree-tools", + "uses": [ + "graphs-mlir" + ], + "produces": [ + "graphs-vmfb" + ], + "per_target": true + }, + { + "id": "parameters", + "kind": "convert", + "tool": "iree-tools", + "uses": [ + "parameters-safetensors" + ], + "produces": [ + "parameters-irpa" + ], + "per_target": true + }, + { + "id": "runtime", + "kind": "build-runtime", + "tool": "iree-tools", + "produces": [ + "runtime-so" + ], + "per_target": true + }, + { + "id": "pack", + "kind": "pack", + "tool": "blueprint-plugin", + "uses": [ + "graphs-vmfb", + "parameters-irpa", + "runtime-so", + "tokenizer-files", + "tool-catalog" + ], + "produces": [ + "pack-dir" + ] + }, + { + "id": "sign", + "kind": "sign", + "tool": "blueprint-plugin", + "uses": [ + "pack-dir" + ], + "produces": [ + "manifest" + ] + } + ], + "outputs": [ + { + "from": "runtime-so", + "role": "runtime", + "path": "artifacts/runtime/libfunctiongemma-ctg.so", + "derived_from": [] + }, + { + "from": "graphs-vmfb", + "role": "model", + "path": "artifacts/model/", + "derived_from": [ + "weights" + ] + }, + { + "from": "parameters-irpa", + "role": "weights", + "path": "artifacts/weights/parameters.irpa", + "derived_from": [ + "weights" + ] + }, + { + "from": "tokenizer-files", + "role": "tokenizer", + "path": "artifacts/tokenizer/tokenizer.json", + "derived_from": [ + "tokenizer" + ] + }, + { + "from": "tool-catalog", + "role": "other", + "path": "artifacts/other/tool-catalog.json", + "derived_from": [ + "tool-catalog" + ] + } + ], + "verification": [ + { + "id": "host-parity", + "kind": "parity", + "description": "Compiled graphs vs. the eager Kotlin reference on the host, greedy decode of the bundled prompts.", + "tolerance": 0.0 + }, + { + "id": "smoke-call", + "kind": "golden", + "description": "A three-function toy catalog shipped with the blueprint: fixed utterances must parse to the expected calls.", + "on_device": false + } + ] +} diff --git a/docs/modules/ROOT/examples/nlu-functiongemma-270m-iree.vulkan-arm64.profile.json b/docs/modules/ROOT/examples/nlu-functiongemma-270m-iree.vulkan-arm64.profile.json new file mode 100644 index 0000000..61fcb25 --- /dev/null +++ b/docs/modules/ROOT/examples/nlu-functiongemma-270m-iree.vulkan-arm64.profile.json @@ -0,0 +1,55 @@ +{ + "spec_version": "0.4", + "kind": "materialization-profile", + "blueprint": { + "id": "nlu-functiongemma-270m-iree", + "version": "0.1.0", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000010", + "source": { + "coordinates": "sk.ainet.cartridge:nlu-functiongemma-270m-iree:0.1.0" + } + }, + "target": "vulkan-arm64", + "cartridge": { + "version": "1.0.0" + }, + "license_acceptance": [ + { + "source": "weights", + "license": "Gemma Terms of Use", + "terms_url": "https://ai.google.dev/gemma/terms", + "accepted_by": "Example Org", + "accepted_at": "2026-09-20" + }, + { + "source": "tokenizer", + "license": "Gemma Terms of Use", + "terms_url": "https://ai.google.dev/gemma/terms", + "accepted_by": "Example Org", + "accepted_at": "2026-09-20" + } + ], + "mirrors": { + "weights": "https://artifacts.example.org/models/functiongemma-270m-it/", + "tokenizer": "https://artifacts.example.org/models/functiongemma-270m-it/" + }, + "inputs": { + "tool-catalog": { + "path": "inputs/tool-catalog.json", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000020", + "license": "proprietary" + } + }, + "measurements": { + "performance": { + "latency_p50_ms": 5900, + "measured_on": "example arm64 board, Vulkan, run 2026-09-20", + "methodology": "bench/README.md#per-utterance" + } + }, + "signing": { + "keyid": "example-org-cartridges-2026", + "signer": "Example Org build service", + "key_ref": "CARTRIDGE_SIGNING_KEY" + } +} diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 05c25a0..adbd058 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -1,6 +1,11 @@ * xref:index.adoc[Cartridge — the concept] * xref:abi.adoc[Runtime ABI] * xref:task-profiles.adoc[Task profiles] +* xref:blueprints.adoc[Blueprints] +** xref:tutorials/build-cartridge-from-blueprint.adoc[Tutorial: build a cartridge from a blueprint] +** xref:how-to/materialize-privately.adoc[How-to: materialize privately] +** xref:explanation/blueprint-vs-cartridge.adoc[Explanation: blueprint vs. cartridge] * xref:threat-model.adoc[Threat model] * xref:roadmap.adoc[Roadmap] * xref:adr/index.adoc[Decision log] +* xref:skeep/007-cartridge-blueprints.adoc[SKEEP-007 — Cartridge blueprints] diff --git a/docs/modules/ROOT/pages/adr/adr-015-cartridge-blueprints.adoc b/docs/modules/ROOT/pages/adr/adr-015-cartridge-blueprints.adoc new file mode 100644 index 0000000..8ae6adb --- /dev/null +++ b/docs/modules/ROOT/pages/adr/adr-015-cartridge-blueprints.adoc @@ -0,0 +1,36 @@ += ADR-015 — Cartridge blueprints: the open recipe; materialization yields the cartridge + +*Status:* proposed (2026-09-20) — design record: xref:skeep/007-cartridge-blueprints.adoc[SKEEP-007]. + +*Context:* A cartridge bundles its weights and compiled artifacts, pinned by digest — the right +shape for something a host loads, the wrong one for a public source repository: weights have a +publisher, a license and often terms that must be accepted; compiled modules are +target-specific; and the build loop ("Developing a cartridge") was eight lines of prose, with no +record of where weights come from, at which revision, or with which tool versions they are +converted, quantized and compiled. Publishing the first cartridges as open source +(Moonshine v2 streaming ASR, FunctionGemma NLU) is blocked on exactly this, and OQ11 +(reproducible builds) has nothing to be reproducible *about*. + +*Decision:* Introduce the *blueprint* — `blueprint.json` +(`cartridge-blueprint.schema.json`): identity, the license of its own code, a *descriptor +template*, *sources* pinned by immutable revision and per-file SHA-256 with a license and a +redistribution class (`allowed` / `restricted` / `acceptance-required`), a *toolchain* pinned to +exact versions, *targets*, optional *flavors*, declared *inputs*, ordered *steps* from a closed +vocabulary (`fetch`, `convert`, `quantize`, `export`, `compile`, `build-runtime`, `measure`, +`pack`, `sign`), and *outputs* mapping step products to manifest artifacts with their +`derived_from` edges. A blueprint MUST NOT contain artifacts of role `weights`, `model` or a +built `runtime`, MUST NOT state measured `performance`/`quality` as descriptor content, and MUST +NOT encode license acceptance. *Materialization* executes a blueprint against a +*materialization profile* (`materialization-profile.schema.json`: target, flavors, cartridge +version, license acceptances, mirrors, inputs, measurements, signing identity) and yields an +ordinary cartridge — same `pack_dir` (ADR-012), descriptor, signed manifest and ABI — whose +manifest gains optional `provenance.blueprint` and `provenance.materialization`. The effective +license (ADR-004) is computed at materialization; the materializing party signs. Everything +host- or organization-specific lives in the profile, which is what lets a public blueprint and a +private cartridge coexist without a fork. + +*Consequences:* Cartridges can be open source without redistributing weights; license terms +travel with the recipe and surface *before* download; Mode B (clone and build) becomes +machine-readable and OQ11 becomes testable; the descriptor honesty rule is preserved because a +recipe may carry only *reference* measurements. Spec v0.4 is additive — existing descriptors and +manifests remain valid. New surface to maintain: two schemas and a materializer (reference +implementation: Gradle plugin `sk.ainet.cartridge.blueprint`). Open: step-vocabulary +extension (OQ-B1), expected digests (OQ-B2), signed blueprints (OQ-B3), third-party +measurements (OQ-B4). diff --git a/docs/modules/ROOT/pages/adr/index.adoc b/docs/modules/ROOT/pages/adr/index.adoc index b22b30a..a6d7b11 100644 --- a/docs/modules/ROOT/pages/adr/index.adoc +++ b/docs/modules/ROOT/pages/adr/index.adoc @@ -19,3 +19,4 @@ vanishes from the list. * xref:adr/adr-012-pack-dir-layout.adoc[ADR-012 (proposed) — Minimal on-disk `pack_dir` layout] * xref:adr/adr-013-manifest-artifact-component-field.adoc[ADR-013 (proposed) — A `component` label on manifest artifacts, for composed-pipeline cartridges] * xref:adr/adr-014-cartridge-flavors.adoc[ADR-014 — `flavors`: additional measured variants (language, runtime tier, ABI) inside one descriptor] +* xref:adr/adr-015-cartridge-blueprints.adoc[ADR-015 (proposed) — Cartridge blueprints: the open recipe; materialization yields the cartridge] diff --git a/docs/modules/ROOT/pages/blueprints.adoc b/docs/modules/ROOT/pages/blueprints.adoc new file mode 100644 index 0000000..7240ae5 --- /dev/null +++ b/docs/modules/ROOT/pages/blueprints.adoc @@ -0,0 +1,328 @@ += Cartridge blueprints +:navtitle: Blueprints + +[.lead] +A *blueprint* is everything needed to build a cartridge except what has to be downloaded to do +it. It lets a cartridge be open source when its weights cannot, or should not, live in a source +repository — and it makes the build loop of xref:index.adoc#developing[Developing a cartridge] +machine-readable. + +**** +*Spec version:* {spec_version} — introduced in v0.4 (SKEEP-007, +xref:adr/adr-015-cartridge-blueprints.adoc[ADR-015]). + +*Status:* normative draft; requirement keywords as in RFC 2119; maturity tags as on +xref:index.adoc[the concept page]. + +*Schemas:* `examples/cartridge-blueprint.schema.json`, +`examples/materialization-profile.schema.json`; worked pair +`examples/nlu-functiongemma-270m-iree.blueprint.json` + +`examples/nlu-functiongemma-270m-iree.vulkan-arm64.profile.json`. + +*Guides:* xref:tutorials/build-cartridge-from-blueprint.adoc[Build a cartridge from a blueprint], +xref:how-to/materialize-privately.adoc[Materialize privately], +xref:explanation/blueprint-vs-cartridge.adoc[Blueprint vs. cartridge]. +**** + +== Why blueprints exist + +*[drafted]* + +A cartridge is self-contained: it *bundles* its runtime and its model artifacts, pinned by digest +in a signed manifest (xref:index.adoc#integrity[Integrity]). That is the right shape for something +an application loads. It is the wrong shape for a public source repository: + +* *Weights are large and not ours to host.* They have a publisher, a license, and often terms + that must be accepted before download. Re-hosting them in a repository copies the bytes and + silently drops the terms. +* *Compiled artifacts are target-specific.* A `.vmfb` for one GPU architecture is useless for + another; committing every combination does not scale, committing one hides the others. +* *The build loop was prose.* Until v0.4 the spec described how a cartridge is produced in + eight numbered lines of text. Where the weights come from, which exact revision, how they are + converted and quantized, with which tool versions — none of it was recorded anywhere a machine + or a reviewer could check. + +A blueprint fixes all three by separating *what is open* from *what is fetched and built*. + +== Terms + +*[drafted]* + +[cols="1,4",options="header"] +|=== +| Term | Meaning + +| *Blueprint* +| The open part of a cartridge: API and runtime *source*, a descriptor template, and a recipe — + pinned sources, a pinned toolchain, targets, ordered steps. Described by one `blueprint.json`. + +| *Materialization* +| Executing a blueprint's recipe for one target: fetch, convert, quantize, export, compile, + build the runtime, measure, pack, sign. Its result is a cartridge. + +| *Materialization profile* +| What the materializing party supplies: target, flavors, the cartridge's version, license + acceptances, mirrors, declared inputs, measurements, signing identity. + +| *Materializer* +| The tool that executes a blueprint against a profile. The reference implementation is the + Gradle plugin `sk.ainet.cartridge.blueprint`; the spec does not depend on it. + +| *Materialized cartridge* +| The result. A cartridge in every sense of xref:index.adoc[the concept page] — same + `pack_dir`, same descriptor, same signed manifest, same ABI — whose manifest additionally + records which blueprint it came from. +|=== + +A blueprint is to a cartridge what a source package with a lockfile is to a binary: not a +lesser cartridge, and not loadable. A host never sees a blueprint. + +== The blueprint contract + +*[drafted]* + +=== A blueprint MUST + +* *Pin every source* by an immutable revision at its publisher *and* by the SHA-256 of every + file taken from it. A branch name is not a revision; a URL without a digest is not a pin. +* *State every source's license and redistribution class* — `allowed`, `restricted`, or + `acceptance-required` — so that what a materializer may do with the result is known before + anything is downloaded (<>). +* *Pin its toolchain*: every tool a step names, at an exact version. Version ranges are not pins. +* *Carry a descriptor template* from which, together with the fields materialization resolves, a + schema-valid capability descriptor results (<>). +* *Declare its inputs*: anything it needs that is host- or organization-specific — a tool + catalog, a vocabulary, a calibration set — is declared as a named input with a schema, and + supplied by the profile. This is how the app-agnosticism rule of + xref:index.adoc[the cartridge contract] carries over: product content never enters a + blueprint, not even as an example. +* *Map every product to a manifest artifact*: role, `pack_dir` path, and the sources and inputs + it is `derived_from`. License obligations trace through builds along exactly these edges + (xref:adr/adr-004-license-composition.adoc[ADR-004]). +* *Ship the source of everything it builds* — including the native runtime that implements the + xref:abi.adoc[Runtime ABI]. The runtime is built at materialization; it is not downloaded. + +=== A blueprint MUST NOT + +* *Contain artifacts of role `weights`, `model`, or a built `runtime`* — no checkpoints, no + compiled modules (`.vmfb`, `.ort`, NPU blobs), no parameter archives, no prebuilt native + libraries. Small *test fixtures* (a golden input and its expected output) are not model + artifacts and are allowed. +* *Contain measured numbers as descriptor content.* `performance` and `quality` are properties + of materialized artifacts on a device (<>). +* *Encode license acceptance.* A blueprint can say acceptance is required; only a profile can + record that someone accepted. +* *Name a device, a fleet, a registry, or a customer.* Targets are described by hardware class + and ABI. Anything more specific belongs to a profile. + +[#anatomy] +== Anatomy of `blueprint.json` + +*[drafted]* — schema: `examples/cartridge-blueprint.schema.json`. + +[listing] +---- +spec_version, kind : "0.4", "blueprint" +id, version : -- (no target) + semver of the RECIPE +license : license of the blueprint's own code and recipe — not of any cartridge +descriptor_template : descriptor minus what materialization resolves (see below) +sources[] : name, role, uri (hf://… | https://…), revision, files[{path, digest}], + license, license_url, redistribution +toolchain{} : tool name → exact version (+ coordinates or container image) +targets[] : id, target{hardware, abi, accelerator}, requirements, params +flavors[] : optional — id, sources[], descriptor_overrides (ADR-014) +inputs[] : name, role, required, schema — supplied by the profile +steps[] : id, kind, tool, uses[], produces[], per_target, params — in order +outputs[] : from (a step product), role, pack_dir path, derived_from[] +verification[] : parity | golden | task-metric | conformance checks that gate packing +reference_measurements[] : what the authors measured — an expectation, never descriptor content +---- + +Step kinds are a closed vocabulary so that a reader — and a license or security reviewer — can +tell what a recipe does without running it: + +[cols="1,4",options="header"] +|=== +| `kind` | What it may do + +| `fetch` | Download sources and verify their digests. Nothing else touches the network. +| `convert` | Change format or layout. Numerics change only by an explicit dtype cast. +| `quantize` | Change numeric precision. What is and is not quantized is stated in `params`. +| `export` | Turn a model into a compiler input (StableHLO, ONNX). +| `compile` | Turn a compiler input into a target artifact. +| `build-runtime` | Build the native runtime library from the blueprint's source. +| `measure` | Produce `performance` / `quality` on the target from the materialized artifacts. +| `pack` | Assemble the `pack_dir` (xref:adr/adr-012-pack-dir-layout.adoc[ADR-012]) and the manifest. +| `sign` | Sign the manifest with the materializing party's key. +|=== + +[#materialization] +== Materialization + +*[drafted]* — profile schema: `examples/materialization-profile.schema.json`. + +Given a blueprint and a profile, a materializer: + +. *Validates* both against their schemas, and checks that the profile's `blueprint.version` + (and `digest`, when given) match the blueprint in hand. +. *Checks licenses before fetching.* For every source the chosen target and flavors need: if + its `redistribution` is `acceptance-required` and the profile has no matching + `license_acceptance` entry, it MUST stop. It MUST NOT offer to accept on anyone's behalf. +. *Fetches* each source from its `uri`, or from the profile's mirror for that source, and + verifies every file's digest. A mirror changes where bytes come from, never which bytes. A + digest mismatch is fatal, not a warning. +. *Validates inputs* against the schema the blueprint declares for them. +. *Runs the steps* in order, with exactly the pinned tool versions. A materializer that + substitutes a tool version MUST record the substitution in provenance and MUST NOT claim the + blueprint's `verification` results. +. *Runs the blueprint's `verification` checks.* A failed check stops materialization before + anything is packed. +. *Resolves the descriptor* (<>). +. *Computes the effective license* from the licenses of every source and input that an output is + `derived_from` (<>). +. *Packs* the `pack_dir` and writes a manifest of `kind: cartridge` whose `provenance` names + the blueprint (<>). +. *Signs* the manifest with the key the profile names. + +The result MUST be indistinguishable, to a host, from a cartridge produced any other way: same +`pack_dir` layout, same schemas, same xref:abi.adoc[ABI]. Everything in +xref:index.adoc#integrity[Integrity] and xref:threat-model.adoc[the threat model] applies +unchanged. Blueprints add no new runtime surface. + +[#descriptor] +=== Resolving the descriptor + +A descriptor template leaves out exactly the fields a blueprint cannot know: + +[cols="1,3",options="header"] +|=== +| Field | Resolved from + +| `spec_version` | the spec version the materializer implements +| `id` | `-`, the target's `cartridge_id`, or the profile's `cartridge.id` +| `version` | the profile's `cartridge.version` — the materializing party owns cartridge versions +| `target` | the chosen blueprint target +| `license` | computed (<>) +| `performance`, `quality` | *measured* — a `measure` step, or the profile's `measurements` +| `flavors` | the blueprint flavors the profile selected, each with its own measurements +|=== + +The honesty rule of xref:index.adoc#anatomy[the descriptor] is not relaxed by a build recipe: +`performance` and `quality` in a materialized descriptor MUST come from a run of *those +materialized artifacts* on the device `measured_on` names. A blueprint MAY publish +`reference_measurements`; they tell a materializer what to expect and MUST NOT be copied into a +descriptor. Two parties materializing the same blueprint for the same target will usually report +different numbers, and both are true. + +[#licensing] +=== Licensing + +Rule 2 of xref:index.adoc#licensing[Licensing] — the effective license must fulfill every +upstream license — is evaluated *at materialization*, because that is the first moment the full +set of upstreams is known: the sources actually used (a flavor may bring a differently licensed +checkpoint), plus every supplied input. + +* The blueprint's own `license` covers its code and recipe. It says nothing about any + cartridge. An MIT blueprint routinely materializes into a cartridge whose effective license is + a model publisher's terms, or `proprietary` once a private input is added. +* `redistribution` tells the materializing party what it may do with the *result*: `allowed` — + the cartridge may be passed on under the computed license; `restricted` — only under + conditions the materializer must check for itself (a revenue cap, a display duty, a field of + use); `acceptance-required` — access itself is conditional, and the result inherits the terms. +* The worked constraint on the concept page is the model case: Moonshine's English checkpoints + are MIT, its non-English ones are under a revenue-capped community license. A Moonshine + blueprint therefore marks the German flavor's source `restricted`, and a materialization that + selects that flavor carries the obligation into its manifest — visibly, instead of nowhere. + +[#provenance] +=== Provenance + +The manifest of a materialized cartridge carries two additional, optional `provenance` members +(manifest schema v2, additive): + +[listing] +---- +provenance.blueprint : id, version, digest (sha256 of blueprint.json), + source { repo, commit | coordinates } +provenance.materialization : profile_digest, target, flavors[], + toolchain { name → version actually used }, materializer +---- + +`provenance.inputs` keeps its existing meaning and now has an obvious content: one entry per +fetched source file and per supplied input, by digest. Together these make the lineage +*source bytes → recipe → artifact bytes* auditable from the manifest alone. + +*Who signs.* The materializing party, with its own key. A blueprint's authors vouch for a +recipe; only whoever ran it can vouch for the bytes that came out. A host's trust store is +therefore keyed by who materialized, exactly as it is today by who published. + +== Distributing and consuming blueprints + +*[drafted]* + +A blueprint is source, and travels like source: + +* *As a repository* — `git clone`, then materialize. This is consumption + xref:index.adoc#consuming[Mode B] with its recipe made explicit. +* *As a published artifact* — the blueprint's code as a library plus its `blueprint.json`, + consumed by version from a package repository together with the materializer plugin. No fork + and no submodule: a consumer's repository contains a profile and its inputs, nothing else. + +Either way the materialized cartridges are distributed by the existing modes — a hub (A) or an +internal artifact store (C). + +[#private] +=== Downstream and private materialization + +*[drafted]* + +The common case for an organization shipping products: the blueprint is public, the cartridge is +not. The organization's repository holds + +* a *profile* — its target, the flavors it ships, its cartridge version line; +* *mirrors* for the sources, so builds do not depend on a public endpoint; +* *license acceptances* made by its legal entity; +* *inputs* — its tool catalog, its vocabulary, its calibration recordings; +* its *signing identity*, and the publishing configuration for its own artifact store. + +It materializes in its own CI and distributes by Mode C. Nothing flows back into the blueprint, +and nothing needs to: every one of those items is declared by the blueprint as something a +profile supplies. If a private materialization needs a change to the *recipe* — a new target, a +different quantization — that change is public by nature and belongs upstream, as a new +blueprint version. + +The effective license of such a cartridge follows from its private inputs like from any other +upstream: typically `proprietary`, with the public sources' obligations still carried in the +manifest. + +== Reproducibility + +*[direction]* + +A blueprint is the precondition for xref:index.adoc[OQ11], not its answer. With sources and +toolchain pinned, two materializations for the same target *can* be compared byte for byte; +whether today's exporters and compilers are deterministic end to end is still open. When they +are, a blueprint MAY publish expected artifact digests per target, and the Mode B trust path — +rebuild and compare, instead of trusting a signature — becomes real. Until then, a materialized +cartridge is trusted because of who signed it. + +== What a blueprint is not + +*[drafted]* + +* *Not a cartridge.* It cannot be loaded, has no manifest, and makes no performance claim. +* *Not a model card.* It describes how artifacts are produced, not how a model was trained. +* *Not a build service.* xref:index.adoc[Cartridge as a service] is a place to *run* + materializations; a blueprint is what it would run. +* *Not a weights-update mechanism.* A `weights-update` manifest still pins a base *cartridge* by + digest. A blueprint whose source revision moves is a new blueprint version, and materializing + it yields a new base. + +== Open questions + +*[drafted]* + +* *OQ-B1 — Step vocabulary.* Closed today. Whether backends need an extension mechanism, or new + kinds are added by spec revision only. +* *OQ-B2 — Expected digests.* The shape of per-target reproducibility claims, once OQ11 allows them. +* *OQ-B3 — Signed blueprints.* A blueprint is pinned by digest in the profile; whether blueprint + releases also need publisher signatures, and how that composes with the trust roles of OQ6. +* *OQ-B4 — Measurement on hardware the materializer does not own.* Whether a third party's + measurement can be referenced from a descriptor, and with what attribution. diff --git a/docs/modules/ROOT/pages/explanation/blueprint-vs-cartridge.adoc b/docs/modules/ROOT/pages/explanation/blueprint-vs-cartridge.adoc new file mode 100644 index 0000000..f731bf2 --- /dev/null +++ b/docs/modules/ROOT/pages/explanation/blueprint-vs-cartridge.adoc @@ -0,0 +1,80 @@ += Blueprint vs. cartridge +:navtitle: Explanation: blueprint vs. cartridge + +[.lead] +Why the specification has two objects where a repository with a build script might seem enough, +and why the line between them runs exactly at the weights. + +== Two audiences, two shapes + +A *host* wants something sealed: bytes it can verify against a signature and load, with no +network access, no build, no decisions. That is a cartridge, and nothing about blueprints +changes it. + +A *reader of source* — a contributor, a reviewer, a license auditor, someone porting the model +to new hardware — wants the opposite: everything open to inspection, nothing opaque, nothing +that had to be trusted because it was too big to read. A directory of compiled modules and a +500 MB parameter archive fails that reader completely, even under an open-source license. + +The two shapes cannot be the same object. A blueprint is the second shape; materialization is +the function from one to the other. + +[listing] +---- + blueprint (open, small, reviewable) cartridge (sealed, large, verifiable) + ───────────────────────────────────── ───────────────────────────────────── + API + runtime SOURCE runtime LIBRARY + descriptor TEMPLATE ──► descriptor, with measured numbers + sources: uri + revision + digest materialize weights, compiled model (bytes) + toolchain pins, ordered steps ──► manifest: digests, licenses, provenance + license of the code EFFECTIVE license of everything inside + signed by: nobody needs to signed by: whoever materialized +---- + +== Why the line runs at the weights + +Three different things change hands at that line. + +*Ownership.* Code in a blueprint belongs to its authors and is theirs to license. Weights belong +to a publisher who set terms — sometimes permissive, sometimes capped by revenue, sometimes +conditional on accepting an agreement. A repository that contains the weights has made a +redistribution decision on behalf of everyone who clones it. A blueprint makes none: it names +the publisher, the revision and the terms, and the person who builds decides. + +*Size and specificity.* Source is kilobytes and target-independent. A compiled module is +megabytes and useful for one backend on one architecture. Keeping them apart means the open +part stays small enough to actually read, and targets can be added by changing a recipe instead +of committing another set of binaries. + +*Trust.* A recipe can be trusted by reading it. Bytes cannot — they are trusted because someone +accountable signed them, or because you rebuilt them yourself. So a blueprint needs review, and +a cartridge needs a signature by the party that ran the build. Conflating the two is how +"open-source model packages" end up with unauditable binaries in their trees. + +== Why measured numbers stay out of the recipe + +The descriptor's rule is that performance and quality are *measured on the stated target and +reported honestly*. If a blueprint carried those numbers, every cartridge built from it would +inherit a claim about hardware its builder may never have touched. So the template leaves them +out, the schema rejects them, and a blueprint can offer only `reference_measurements` — "this is +what we saw" — which a materializer compares against and never copies. Two honest builders of +the same blueprint will publish different numbers. That is the system working. + +== Why product content is an input, not an example + +A function-calling model needs a catalog of functions; a keyword spotter needs a vocabulary. +Shipping "a sample" inside the blueprint looks harmless and is how product knowledge leaks into +public components: the sample is always derived from the real one. The cartridge contract +already draws this line for code — no application concepts inside a cartridge — and blueprints +draw it for the build: the slot is declared, with a schema; the content arrives through a +profile. A public blueprint ships at most a toy that could not be mistaken for anyone's product. + +== What this buys, and what it costs + +It buys cartridges that can be open source at all; license terms that surface before a download +instead of after a release; private builds without forks; and a defined object for reproducible +builds to be about. + +It costs a second artifact to specify and keep true. A blueprint that is not the way the +cartridge is *actually* built is worse than none — which is why the first blueprints replace the +hand-run build paths of the existing cartridges instead of documenting them from the side. diff --git a/docs/modules/ROOT/pages/how-to/materialize-privately.adoc b/docs/modules/ROOT/pages/how-to/materialize-privately.adoc new file mode 100644 index 0000000..c88581b --- /dev/null +++ b/docs/modules/ROOT/pages/how-to/materialize-privately.adoc @@ -0,0 +1,122 @@ += Materialize a public blueprint privately +:navtitle: How-to: materialize privately + +[.lead] +You ship products on your own hardware and want your cartridges built from a public blueprint — +with your targets, your inputs, your keys and your artifact store — without forking it. + +This is xref:blueprints.adoc#private[downstream materialization]: everything that is yours lives +in a *profile repository*; the blueprint is consumed by version. + +== Before you start + +* You can already xref:tutorials/build-cartridge-from-blueprint.adoc[build a cartridge from a blueprint]. +* You have an internal artifact store (consumption xref:index.adoc#consuming[Mode C]) and a CI + system that can hold a signing key. +* Someone authorized has read the licenses of the blueprint's sources. For + `acceptance-required` and `restricted` sources that is a decision your organization makes, not + a build setting. + +== 1. Create a profile repository + +It contains no model code and no copy of the blueprint: + +[listing] +---- +my-cartridges/ +├── settings.gradle.kts plugin + blueprint resolved BY VERSION +├── build.gradle.kts +├── profiles/ +│ ├── product-a.json one profile per (target, flavor set) you ship +│ └── product-b.json +└── inputs/ your declared inputs — tool catalog, vocabulary, calibration set +---- + +[source,kotlin] +---- +// build.gradle.kts +plugins { + id("sk.ainet.cartridge.blueprint") version "" +} +dependencies { + blueprint("sk.ainet.cartridge:nlu-functiongemma-270m-iree:0.1.0") +} +---- + +Pin the blueprint by exact version and put its digest into every profile +(`blueprint.digest`): that is what makes "0.1.0" mean one recipe in an audit three years from now. + +== 2. Mirror the sources + +Builds should not depend on a public endpoint being up, or on a publisher keeping a revision +available. Copy each source file into your artifact store once, then point the profile at it: + +[source,json] +---- +"mirrors": { + "weights": "https://artifacts.example.org/models/functiongemma-270m-it/", + "tokenizer": "https://artifacts.example.org/models/functiongemma-270m-it/" +} +---- + +A mirror changes where bytes come from, never which bytes: the materializer still verifies every +file against the digests in the blueprint. Mirroring is *hosting a copy for your own builds* — +check that the source's license allows even that; for `acceptance-required` sources the +acceptance covers your organization, not whoever can reach your mirror. + +== 3. Record license acceptances + +One `license_acceptance` entry per `acceptance-required` source, naming your legal entity and +the date. Keep the profile under review like any other compliance record. For `restricted` +sources, write down in the profile repository why your use satisfies the conditions (a revenue +cap, a display duty) — the materializer cannot check that for you. + +== 4. Supply your inputs + +Put product-specific content where the blueprint declared a slot for it: + +[source,json] +---- +"inputs": { + "tool-catalog": { "path": "inputs/product-a-catalog.json", "license": "proprietary" } +} +---- + +The input is validated against the blueprint's schema, packed as a manifest artifact, and its +license enters the effective-license computation — the cartridge becomes `proprietary`, with the +public sources' obligations still listed per artifact. If what you need has no slot, that is a +gap in the blueprint: propose the input upstream rather than patching the recipe locally. + +== 5. Describe your device, measure on it + +Blueprint targets name a hardware class. What is specific to your fleet goes into the profile's +`descriptor_overrides` — typically `requirements.driver` — and `performance` / `quality` come +from a `measure` run on one of your devices. Keep `measured_on` free of hostnames and +addresses; it is published with the descriptor wherever the cartridge goes. + +== 6. Sign and publish in CI + +[source,bash] +---- +./gradlew materializeCartridge -Pprofile=profiles/product-a.json publish +---- + +The signing key lives in CI and is referenced by `signing.key_ref`; it is never in the +repository. Your hosts' trust store pins *your* key for these cartridge ids +(xref:threat-model.adoc[threat model]) — the blueprint's authors vouch for a recipe, you vouch +for the bytes. Publish the `pack_dir` in your usual carrier to your own store. + +== 7. Take blueprint updates deliberately + +A new blueprint version is a new recipe: bump the version and digest in the profiles, rebuild, +re-measure, and compare the task metric against the cartridge you ship — the same regression +discipline xref:index.adoc#integrity[weight updates] follow. A change you need in the *recipe* +(a new target, another dtype) is not private by nature; contribute it upstream and consume it as +the next version. + +== What must not happen + +* Product names, device identifiers or customer data in a pull request to the blueprint. +* A private fork that edits `blueprint.json` "just for us" — its provenance would name a recipe + nobody else can see. +* Copying `reference_measurements` into a profile instead of measuring. diff --git a/docs/modules/ROOT/pages/index.adoc b/docs/modules/ROOT/pages/index.adoc index 6b775e5..347ad40 100644 --- a/docs/modules/ROOT/pages/index.adoc +++ b/docs/modules/ROOT/pages/index.adoc @@ -7,14 +7,14 @@ a real application on real hardware. This page defines the term and its contract ecosystem-wide — not tied to any one application. **** -*Spec version:* 0.2 (see <>). + +*Spec version:* {spec_version} (see <>). + *Status:* normative draft. Requirement keywords MUST / MUST NOT / SHOULD / MAY are used as in RFC 2119. Every section carries a maturity tag: + *[working]* — implemented and running today · *[drafted]* — normative text exists, implementation pending · *[direction]* — intended course, not yet normative. + *Scope:* the whole ecosystem; the first consumer app is one consumer (the first, not the goal). + *Companion pages:* xref:abi.adoc[Runtime ABI], xref:task-profiles.adoc[Task profiles], -xref:threat-model.adoc[Threat model], xref:roadmap.adoc[Roadmap], xref:adr/index.adoc[Decision log]. + +xref:blueprints.adoc[Blueprints], xref:threat-model.adoc[Threat model], xref:roadmap.adoc[Roadmap], xref:adr/index.adoc[Decision log]. + *Related:* the reference cartridges `asr-whisper-tiny-npu` (Whisper on a vendor NPU) and `asr-moonshine-ort-cpu` (Moonshine on ONNX Runtime), the SKaiNET/IREE Moonshine v2 engine, and the NPU cartridge builder. @@ -251,6 +251,10 @@ separate, per-cartridge licensing choice — <>. `examples/cartridge-manifest.schema.json` |=== +A cartridge *contains* its model artifacts. The open, weight-free description of how those +artifacts are produced — pinned sources, pinned toolchain, ordered steps — is a +xref:blueprints.adoc[blueprint] (v0.4); building a cartridge from one is *materialization*. + === Capability descriptor (v0.3 shape) [listing] @@ -471,6 +475,10 @@ of its fallback, and the worked example (Moonshine standing in for Whisper) viol == Developing a cartridge *[working]* — the loop below is how the two existing cartridges were actually produced. +Since v0.4 the same loop has a machine-readable form: a xref:blueprints.adoc[blueprint] pins +steps 1–3 (sources, toolchain, export, compile) and 4–7 (`build-runtime`, `measure`, `pack`, +`sign`) as a recipe a materializer executes. Step 6 is unchanged by that: numbers are measured on +the materialized artifacts, never carried by the recipe. [listing] ---- @@ -527,7 +535,9 @@ metadata. No hub required. A cartridge's source repo (the shape the two reference cartridges already follow) bundles the packaging step itself, so -`git clone && ` produces a conformant package locally. +`git clone && ` produces a conformant package locally. Since v0.4 that repo is a +xref:blueprints.adoc[blueprint]: the weights are not in it — they are fetched from their +publisher, pinned by revision and digest, under terms the builder accepts explicitly. This unlocks a trust path independent of signatures: *if the build is reproducible*, a consumer can rebuild from source and check that their own artifact digests match the digests in a @@ -550,6 +560,11 @@ signature(s) still get verified before load. For a single-publisher internal set store is trivial: pin the company's own key(s) once — baked into build images, provisioned on managed devices. This is the practical answer to OQ4 for the internal audience. +When the cartridge comes from a public blueprint, this is +xref:blueprints.adoc#private[downstream materialization]: the organization adds a profile +(target, mirrors, license acceptances, its own inputs, its signing key) and nothing else — the +blueprint is consumed by version, not forked. + [#selection] == Multiple cartridges → selection is the host's job @@ -625,6 +640,8 @@ be promoted the same way. * *A framework* — SKaiNET itself authors and runs models; it is the universe the cartridge lives in, not a cartridge. * *A compiler / build tool* — the NPU cartridge builder _produces_ cartridge model artifacts; it is not one. +* *A blueprint* — the recipe and source a cartridge is built from (xref:blueprints.adoc[]); it has + no weights, no manifest and cannot be loaded. * *An app engine that embeds product logic* — that is an _adapter_, not a cartridge. == Relationship to the rest of the SKaiNET universe @@ -686,7 +703,8 @@ be promoted the same way. revenue-capped commercial use, display duty) that the field structure must be able to carry. * *OQ11 — Reproducible builds.* Whether the NPU builder / an ORT export / an IREE compile can be made bit-for-bit deterministic — prerequisite for the Mode-B trust path to mean anything beyond - "I trust my own laptop." + "I trust my own laptop." v0.4 supplies the other prerequisite: a xref:blueprints.adoc[blueprint] + pins sources and toolchain, so there is now something well-defined to be deterministic *about*. * *OQ12 — Task profiles.* **Resolved (drafted)** by xref:task-profiles.adoc[] / ADR-005 for `asr/v1` and ADR-008 for `yolo/v1` (detection); `embedding` and `tts` remain open. * *OQ13 — On-demand delivery failure as a first-class capability?* ADR-009 (platform asset @@ -698,6 +716,17 @@ be promoted the same way. [#changelog] == Changelog +*v0.4 (2026-09-20)* — *blueprints* (SKEEP-007, ADR-015, xref:blueprints.adoc[]): the open, +weight-free recipe a cartridge is built from — sources pinned by revision and digest with a +license and a redistribution class, a pinned toolchain, targets, flavors, declared inputs, steps +from a closed vocabulary, and outputs mapped to manifest artifacts — plus *materialization*, the +profile-driven build that turns one into an ordinary cartridge. New schemas +`cartridge-blueprint.schema.json` and `materialization-profile.schema.json` with a worked pair; +manifest `provenance.blueprint` / `provenance.materialization` (optional, additive); the +descriptor's `spec_version` accepts `0.3` and `0.4` (shape unchanged). First guides: a tutorial, +a how-to for private materialization, an explanation page. Also fixed: this page's header still +said spec version 0.2. + *v0.3 (2026-09-09)* — descriptor `flavors` (ADR-014): one package can now cover several independently-addressable, independently-measured identities (a bundled language, a runtime tier, an ABI) sharing one task/family/io/license identity, instead of needing one descriptor diff --git a/docs/modules/ROOT/pages/roadmap.adoc b/docs/modules/ROOT/pages/roadmap.adoc index 337b925..eb3f793 100644 --- a/docs/modules/ROOT/pages/roadmap.adoc +++ b/docs/modules/ROOT/pages/roadmap.adoc @@ -28,6 +28,12 @@ maturity tags, ADR log; Runtime ABI v0.1 (`cartridge-abi/include/cartridge_abi.h expressions + entitlement-restricted markers); generates the NOTICE file including display duties (e.g. "Powered by Moonshine AI"); *fails on undeclared licenses* — the current Whisper-on-NPU cartridge state fails this gate by design. +* *Blueprint materializer* (v0.4, xref:blueprints.adoc[]) — Gradle plugin + `sk.ainet.cartridge.blueprint`: validate `blueprint.json` + profile, license check before + fetch, digest-verified fetch (mirrors allowed), pinned-toolchain steps, `verification` gate, + descriptor resolution, effective-license computation (shares the license gate above), + `pack_dir` + manifest with `provenance.blueprint`, signing. First blueprints: + `asr-moonshine-v2-streaming-iree`, `nlu-functiongemma-270m-iree`. == Phase 3 — Retrofit the two real cartridges diff --git a/docs/modules/ROOT/pages/skeep/007-cartridge-blueprints.adoc b/docs/modules/ROOT/pages/skeep/007-cartridge-blueprints.adoc new file mode 100644 index 0000000..0bfa65f --- /dev/null +++ b/docs/modules/ROOT/pages/skeep/007-cartridge-blueprints.adoc @@ -0,0 +1,196 @@ += SKEEP-007: Cartridge blueprints +:navtitle: SKEEP-007 — Cartridge blueprints +:description: Proposal to add blueprints to the cartridge specification: the open, weight-free recipe a cartridge is built from, and materialization, the profile-driven build that turns one into an ordinary signed cartridge. + +Status: Draft + +Audience: cartridge-spec maintainers; authors of cartridges, adapters and cartridge tooling; anyone who wants to publish a cartridge without redistributing its weights + +Created: 2026-09-20 + +Tracking issue: https://github.com/SKaiNET-developers/SKaiNET-cartridge/issues/6[SKaiNET-cartridge#6] + +Decision record: xref:adr/adr-015-cartridge-blueprints.adoc[ADR-015] + +Numbering: SKEEP numbers come from the https://skainet-developers.github.io/SKaiNET/[SKaiNET] series; this proposal lives here because it changes this specification. + +== Summary + +A cartridge bundles its weights and compiled model artifacts, pinned by digest in a signed +manifest. That is what a host should load and what a public source repository should not +contain. This proposal adds the missing half: a *blueprint* — identity, a descriptor template, +sources pinned by revision and digest with their license and redistribution class, a pinned +toolchain, targets, declared inputs, ordered steps and an output mapping — and *materialization*, +which executes a blueprint against a *materialization profile* and yields an ordinary cartridge +whose manifest records the blueprint as provenance. Normative text: +xref:blueprints.adoc[Blueprints]. Spec v0.4; additive. + +== Motivation + +* *The first cartridges cannot be published as they are.* The Moonshine v2 streaming ASR and + FunctionGemma NLU cartridges exist and work, but their repositories either contain hundreds of + megabytes of compiled modules and parameter archives, or reference them out of band. Neither + shape is publishable: the weights have a publisher and terms (MIT for Moonshine's English + checkpoints, a revenue-capped community license for its other languages, Gemma's Terms of Use + for FunctionGemma), and re-hosting them drops those terms on the floor. +* *The build loop is unrecorded.* "Developing a cartridge" is eight lines of prose. Which + upstream revision, which files, how they were converted and quantized, which exporter and + compiler versions — known to whoever built it, written down nowhere a reviewer or a machine can + check. `provenance.inputs` records digests after the fact; `provenance.producer` is a free + string. +* *OQ11 has no subject.* "Can builds be made reproducible" cannot be tested while "the build" + is not a defined object. +* *The open/closed seam already exists one level up and works.* Cartridge vs. adapter separates + public, app-agnostic code from product policy. The same seam is needed inside the build: a + public recipe, and a private place for the target fleet, license acceptances, mirrors, + product-specific inputs and signing keys. Without it the only way to build privately is to + fork. + +== Goals + +* A cartridge's code, interface, descriptor template and *complete build recipe* can be open + source while no weight and no compiled artifact is redistributed. +* License terms are part of the recipe and are evaluated *before* anything is downloaded; + acceptance is an explicit, recorded act of the party that builds. +* A materialized cartridge is an ordinary cartridge. Hosts, adapters, the ABI, the threat model + and verification are untouched. +* The descriptor honesty rule survives: a recipe cannot supply `performance` or `quality`. +* A private build of a public blueprint needs no fork — only a profile. +* The lineage *source bytes → recipe → artifact bytes* is auditable from the manifest. + +== Non-Goals + +* Reproducible builds themselves (OQ11). Blueprints make them testable, not true. +* A hub, registry or build service. Blueprints are source; materialized cartridges are + distributed by the existing modes A and C. +* Describing how a model was trained. A blueprint starts at a published checkpoint. +* A general-purpose build system. The step vocabulary is closed and small on purpose. +* Changing weight-only updates. A `weights-update` still pins a base cartridge by digest. + +== Proposed Design + +The normative text is xref:blueprints.adoc[]. In outline: + +=== The blueprint (`blueprint.json`) + +`spec_version`, `kind: blueprint`, `id` (`--`, no target), `version` (of the +recipe), `license` (of the blueprint's own content), `descriptor_template`, `sources[]`, +`toolchain{}`, `targets[]`, `flavors[]`, `inputs[]`, `steps[]`, `outputs[]`, `verification[]`, +`reference_measurements[]`. Schema: `examples/cartridge-blueprint.schema.json`. + +Design decisions worth recording: + +* *Two pins per source.* An immutable publisher revision *and* a SHA-256 per file. The revision + says where to look; the digest is what is trusted. A mirror may replace the first, never the + second. +* *Redistribution class on every source* — `allowed`, `restricted`, `acceptance-required`. It + is the one license fact a materializer needs before it acts, and it cannot be derived from an + SPDX id alone (a community license with a revenue cap has no SPDX id and is not "proprietary"). +* *A closed step vocabulary* — `fetch`, `convert`, `quantize`, `export`, `compile`, + `build-runtime`, `measure`, `pack`, `sign`. A reviewer can see that only `fetch` touches the + network and that numerics change only in `quantize` (and declared dtype casts in `convert`) + without executing anything. +* *Declared inputs.* Anything host- or organization-specific — a function-calling model's tool + catalog is the motivating case — is an input with a schema, supplied by the profile. This is + the cartridge contract's app-agnosticism rule applied to the build: product content never + enters the blueprint, not even as a sample. +* *The template excludes what cannot be known.* `id`, `version`, `target`, `license`, + `performance`, `quality` are rejected by the schema inside `descriptor_template`. + `reference_measurements` exists so authors can still say what they saw — as an expectation. + +=== Materialization and the profile + +`materialization-profile.schema.json`: `blueprint{id, version, digest, source}`, `target`, +`flavors[]`, `cartridge{id?, version}`, `license_acceptance[]`, `mirrors{}`, `inputs{}`, +`measurements{}`, `descriptor_overrides{}`, `signing{keyid, signer, key_ref}`. + +The procedure is ten ordered steps (validate → license check → fetch + verify → validate inputs +→ run steps → verification gate → resolve descriptor → compute effective license → pack → +sign). Two rules carry most of the weight: a materializer *MUST stop* at an +`acceptance-required` source without a matching acceptance record and MUST NOT offer to accept; +and a digest mismatch is fatal. + +=== Manifest + +Two optional members under `provenance` — `blueprint{id, version, digest, source}` and +`materialization{profile_digest, target, flavors, toolchain, materializer}`. `profile_digest` +pins a profile without publishing it, so a private profile stays private and the build stays +auditable to whoever holds it. + +=== Reference implementation + +A Gradle plugin, `sk.ainet.cartridge.blueprint`, in the blueprints repository, with tasks that +mirror the step kinds and an umbrella `materializeCartridge -Pprofile=…`. Model-side steps are +Kotlin on the SKaiNET stack (weight loading, conversion, quantization, StableHLO export); +compilation is delegated to IREE tools (StableHLO → IREE). The specification does not depend on +the plugin. + +== Requirements + +=== Functional + +. The worked blueprint and profile validate against their schemas; the descriptor resolved from + them validates against `cartridge-descriptor.schema.json`. +. A blueprint with a `performance` block in its template, a source file without a digest, or an + unknown redistribution class is rejected by the schema. +. Existing descriptors and manifests validate unchanged against the v0.4 schemas. +. A manifest carrying `provenance.blueprint` verifies with the existing `verify_manifest.py`. + +=== Non-Functional + +. No new runtime surface: nothing a host loads or calls changes. +. A blueprint is reviewable by reading: no step hides a download or a precision change. +. Nothing organization-specific is expressible in a blueprint without misusing a field. + +== Compatibility & Migration + +Additive. Descriptor shape is unchanged; its `spec_version` accepts `0.3` and `0.4`. Manifest +schema v2 gains two optional `provenance` members. No existing cartridge needs to change, and a +cartridge need not have a blueprint. + +Migrating an existing cartridge repository to a blueprint: remove model artifacts and prebuilt +libraries from the tree (and from history, by starting the public repository fresh); describe +the sources and steps in `blueprint.json`; move device-, fleet- and product-specific content +into a profile and declared inputs; replace measured descriptor fields with +`reference_measurements`. + +== Rollout Plan + +. *Spec* — this proposal, ADR-015, `blueprints.adoc`, the two schemas with a worked pair, + manifest provenance, concept-page cross-references, first guides. _(this change)_ +. *Materializer* — the Gradle plugin, exercised by a synthetic blueprint in its functional tests. +. *First blueprints* — `nlu-functiongemma-270m-iree`, then `asr-moonshine-v2-streaming-iree` + (the latter first needs its weight conversion ported to Kotlin). +. *Proof* — existing private cartridges re-materialized from the public blueprints and shown to + pass the same golden checks as the hand-built ones. + +== Acceptance Criteria + +* All functional requirements above hold in CI. +* The documentation site builds with the new pages and navigation. +* Both first blueprints materialize end to end for at least one target each, from a clean + machine, by following the published tutorial. +* At least one private, downstream materialization of a public blueprint exists that required no + change to the blueprint. + +== Risks + +* *The recipe drifts from reality* if cartridges keep being built by hand. Mitigation: the + first blueprints replace, not accompany, the existing build paths. +* *Publisher endpoints change or disappear.* Digests keep a mirrored copy trustworthy; they do + not make a vanished file reappear. Mirrors are the mitigation and are first-class for that + reason. +* *License classification is a judgment.* `restricted` vs. `allowed` is stated by blueprint + authors and can be wrong. The materializing party remains responsible; the field makes the + question visible, it does not answer it for them. +* *A closed step vocabulary may be too small* for a backend not yet met (OQ-B1). +* *Compiler availability.* A recipe is only as open as its toolchain; a step whose tool is not + publicly obtainable must say so, and must document an equivalent public invocation. + +== Open Questions + +OQ-B1 step-vocabulary extension · OQ-B2 expected digests per target · OQ-B3 signed blueprint +releases and their trust role · OQ-B4 referencing third-party measurements — see +xref:blueprints.adoc[]. + +== References + +* xref:blueprints.adoc[Blueprints] — normative text +* xref:adr/adr-015-cartridge-blueprints.adoc[ADR-015], xref:adr/adr-004-license-composition.adoc[ADR-004], + xref:adr/adr-012-pack-dir-layout.adoc[ADR-012], xref:adr/adr-014-cartridge-flavors.adoc[ADR-014] +* xref:index.adoc#developing[Developing a cartridge], xref:index.adoc#consuming[Consuming a cartridge] (Modes B and C), OQ11 diff --git a/docs/modules/ROOT/pages/tutorials/build-cartridge-from-blueprint.adoc b/docs/modules/ROOT/pages/tutorials/build-cartridge-from-blueprint.adoc new file mode 100644 index 0000000..7bf5f13 --- /dev/null +++ b/docs/modules/ROOT/pages/tutorials/build-cartridge-from-blueprint.adoc @@ -0,0 +1,211 @@ += Build a cartridge from a blueprint +:navtitle: Tutorial: build from a blueprint + +[.lead] +You will take an open-source blueprint, download the model it names from the model's own +publisher, convert and compile it for an Android device, and end up with a signed cartridge on +disk that an app can load. Nothing is downloaded from the blueprint's repository except code. + +[IMPORTANT] +==== +*Preview.* The materializer — the Gradle plugin `sk.ainet.cartridge.blueprint` — is being built +(xref:roadmap.adoc[roadmap, Phase 2]). This tutorial describes its intended interface; task and +property names may change until its first release. The file formats it reads are normative +today: xref:blueprints.adoc[Blueprints]. +==== + +== What you will build + +A `pack_dir` for the `nlu-functiongemma-270m-iree` blueprint, target `vulkan-arm64`: + +[listing] +---- +build/cartridge/nlu-functiongemma-270m-iree-vulkan-arm64/ +├── descriptor.json resolved from the blueprint's template + your profile +├── manifest.json digests, licenses, provenance.blueprint — signed by YOU +└── artifacts/ + ├── runtime/ native library, built from the blueprint's source + ├── model/ compiled graphs (.vmfb) + ├── weights/ parameter archive (.irpa) + ├── tokenizer/ + └── other/tool-catalog.json your input +---- + +== What you will learn + +* How a blueprint pins what it downloads, and why you accept a model license yourself. +* What the steps between a checkpoint and a compiled module are, and which tool runs each. +* Which descriptor fields a recipe can never fill in for you. + +== Prerequisites + +* JDK 21 or newer, and Git. +* Android SDK with the NDK version the blueprint's README names (the runtime is cross-compiled). +* Docker — the compile steps run in the *IREE tools* images (StableHLO → IREE). +* An account at the model's publisher, with the model's terms accepted *there* (for the + example: the Gemma Terms of Use), and an access token in `HF_TOKEN`. +* About 3 GB of disk, and for Step 7 an arm64 Android device with a Vulkan-capable GPU. + +== Step 1 — Get a blueprint + +[source,bash] +---- +git clone https://github.com/SKaiNET-developers/skainet-cartridge-blueprints.git +cd skainet-cartridge-blueprints/blueprints/nlu-functiongemma-270m-iree +---- + +Open `blueprint.json`. Three parts are worth reading before you run anything: + +`sources`:: Where the weights come from — a publisher URI, an immutable `revision`, and a + SHA-256 for every file. `redistribution: acceptance-required` means the download will refuse + to start until *you* record that you accepted the publisher's terms. +`toolchain`:: The exact versions of SKaiNET, SKaiNET-transformers and IREE tools the recipe was + written against. +`steps`:: The whole build, in order. Only `fetch` touches the network; only `quantize` (and + declared dtype casts in `convert`) changes numbers. + +Check that the repository really contains no model: `git ls-files | grep -E '\.(safetensors|gguf|vmfb|irpa|so)$'` +prints nothing. + +== Step 2 — Write a materialization profile + +A profile is *your* half of the build. Create `profiles/my-device.json`: + +[source,json] +---- +{ + "spec_version": "0.4", + "kind": "materialization-profile", + "blueprint": { "id": "nlu-functiongemma-270m-iree", "version": "0.1.0" }, + "target": "vulkan-arm64", + "cartridge": { "version": "0.1.0" }, + "license_acceptance": [ + { "source": "weights", "license": "Gemma Terms of Use", + "terms_url": "https://ai.google.dev/gemma/terms", + "accepted_by": "", "accepted_at": "" }, + { "source": "tokenizer", "license": "Gemma Terms of Use", + "terms_url": "https://ai.google.dev/gemma/terms", + "accepted_by": "", "accepted_at": "" } + ], + "inputs": { + "tool-catalog": { "path": "samples/toy-catalog.json", "license": "MIT" } + }, + "signing": { "keyid": "my-dev-key", "signer": "", "key_ref": "CARTRIDGE_SIGNING_KEY" } +} +---- + +The acceptance entries are a record of something you did, not a switch: read the terms first. +The tool catalog is an *input* because the functions a model may call belong to a host, never +to a blueprint; the repository ships only a three-function toy catalog for trying things out. + +== Step 3 — Fetch and verify the sources + +[source,bash] +---- +./gradlew blueprintFetch -Pprofile=profiles/my-device.json +---- + +Remove one `license_acceptance` entry and run it again: it stops before any download and names +the source and its terms. Put it back. After a successful run, every file under +`build/blueprint/sources/` has been checked against the digest in `blueprint.json`; a mismatch +fails the build. + +== Step 4 — Convert and quantize + +[source,bash] +---- +./gradlew blueprintConvert -Pprofile=profiles/my-device.json +---- + +This is Kotlin on the SKaiNET stack: the checkpoint is read with the SafeTensors reader and +written as the external-parameter file the exported graphs will reference. For this target the +recipe stores weights as `bf16` and computes in `f32`; the step prints exactly which tensors +were cast and which were left alone. There is no integer quantization in this recipe — when a +blueprint has one, it is a separate `quantize` step with its own parameters. + +== Step 5 — Export StableHLO + +[source,bash] +---- +./gradlew blueprintExport -Pprofile=profiles/my-device.json +---- + +The model is traced in Kotlin into StableHLO modules, one per graph the runtime needs (prefill, +prefill-with-past, decode-with-past). Weights stay *external* to the modules, so the `.mlir` +files are small and readable: open one and look for `stablehlo.` operations and the +`parallel_dims` schedule attribute on attention. + +== Step 6 — Compile with IREE tools (StableHLO → IREE) + +[source,bash] +---- +./gradlew blueprintCompile blueprintBuildRuntime -Pprofile=profiles/my-device.json +---- + +`blueprintCompile` turns each module into a `.vmfb` for the target's backend (`vulkan-spirv` +here) and converts the parameters into an `.irpa` archive. `blueprintBuildRuntime` cross-builds +the native library that implements the xref:abi.adoc[Runtime ABI] from the blueprint's C source. +Both run inside the pinned IREE tools images. The blueprint's README lists the equivalent plain +`iree-compile` invocation for every step, for building without the images. + +== Step 7 — Measure + +Performance is a property of *your* artifacts on *your* device, so no recipe can fill it in: + +[source,bash] +---- +./gradlew blueprintMeasure -Pprofile=profiles/my-device.json # device connected over adb +---- + +The step runs the blueprint's benchmark workload on the device and writes `performance` into +the resolved descriptor. The blueprint's `reference_measurements` tell you roughly what to +expect; they are never copied. For `task: asr` blueprints the same step also produces the +mandatory `quality` block. + +== Step 8 — Pack, sign, verify + +[source,bash] +---- +export CARTRIDGE_SIGNING_KEY="$(cat ~/keys/my-dev-key.pem)" +./gradlew materializeCartridge -Pprofile=profiles/my-device.json +---- + +`materializeCartridge` runs anything not yet done, then the blueprint's `verification` checks, +computes the effective license, writes the `pack_dir` and signs the manifest. Look at two +things in `manifest.json`: + +* `effective_license` — not MIT, although the blueprint is: the weights' terms carry over. +* `provenance.blueprint` — the blueprint's id, version and digest, and `provenance.inputs` with + the digest of every file you downloaded. + +Verify it the way a host or a staging step would — `verify_manifest.py` from this repository's +`examples/`, given the key id from your profile and the matching public key: + +[source,bash] +---- +python3 verify_manifest.py build/cartridge/nlu-functiongemma-270m-iree-vulkan-arm64/manifest.json \ + my-dev-key=$HOME/keys/my-dev-key.pub +---- + +== Step 9 — Use it from an Android app + +Add the blueprint's Kotlin API as a dependency and point it at the `pack_dir` — staged into the +app's assets, or side-loaded during development: + +[source,kotlin] +---- +dependencies { + implementation("sk.ainet.cartridge:nlu-functiongemma-270m-iree:0.1.0") +} +---- + +The API artifact contains code only; the app gets its model from the cartridge you just built. + +== Where to go next + +* xref:how-to/materialize-privately.adoc[Materialize privately] — mirrors, your own inputs, your + own registry, no fork. +* xref:explanation/blueprint-vs-cartridge.adoc[Blueprint vs. cartridge] — why the split is where it is. +* Model-specific guides in the blueprints repository: download, convert, quantize and compile, + step by step, for Moonshine v2 streaming ASR and FunctionGemma. +* xref:blueprints.adoc[Blueprints] — the normative text.