Serve documentation from the operator UI

This commit is contained in:
Emil
2026-08-02 15:50:07 +03:00
parent 284aef5d6f
commit f20cc7fe00
72 changed files with 3509 additions and 698 deletions
+26 -17
View File
@@ -1,26 +1,35 @@
COMPLETED COMPLETED
# Session Goal # Session Goal
адаптируй код под sdk, где это необходимо. при надобности доработай SDK. главное чтобы workloads можно было дописывать не трогая остальной код программы, так как он в будущем будет закрытым. Workloads - в первую очередь пользовательские скрипты, поэтому sdk должен полностью покрывать необходимый функционал. пропиши абсолютно полную документацию scimesh в mkdocs.
## Plan ## Plan
1. SDK: добавить высокоуровневый каркас MapReduceWorkload (scimesh/sdk/batch.py) — манифест/стейджи/definition собираются автоматически, планировщик/раннер/редуктор — общий скелет с хуками (partition_input, compute_shard, parse/validate_partial_keys, reduce_partials, domain_validate). 1. Каталог `mkdocs/` — самостоятельный источник сайта (docs_dir: mkdocs); проектные `docs/` не трогаем.
2. Рефакторинг: descriptor-batch, similarity-search, similarity-graph переписать на базовый класс (поведение/байты не меняются — парность покрыта тестами). 2. mkdocs.yml: docs_dir, полный nav (Home → SDK guides → API reference (все модули scimesh.sdk) → Documentation approach), edit_uri.
3. Worker (закрываемый код): обобщить SciMeshRunner — загрузка ворклоадов из конфига/дискавери (allowlist), инвентарь из конфига воркера, fail-closed для неподдерживаемых форм; конфиг: SCIMESH_CAPABILITIES, SCIMESH_WORKLOAD_ALLOWLIST. 3. Контент:
4. CLI: добавить общий `scimesh workload list|run` (generic SDK-инструмент, без workload-специфичной логики) — пользовательские скрипты можно запускать локально без правки остального кода. - index.md — лендинг: что такое SciMesh, архитектура, быстрый старт.
5. Тесты: test_sdk_batch.py (каркас + хуки + fail-closed), тесты воркера на не-search ворклоаде, CLI-тесты; регрессия парности. - sdk/overview.md — концепция SDK (framework-only, core-batch-v1, безопасность, fail-closed).
6. Документация: workload-sdk.md (авторский гайд на базе MapReduceWorkload), handoff, STATUS. - sdk/authoring-workloads.md — полный гайд MapReduceWorkload (атрибуты, хуки, полный пример molwt-filter, регистрация, тесты).
7. Полный прогон pytest, финальная верификация. - sdk/cli.md — scimesh workload list/run/export + env.
- sdk/worker-integration.md — воркер: claim/heartbeat/upload, SDK-мост, allowlist, ограничения v1.
- api/ — автогенерация mkdocstrings для ВСЕХ модулей scimesh.sdk (batch, artifacts, manifest, workflow, plans, registry, verification, runtime, resources, conformance, execution, identity, integrity, schema, protocols) + index.md.
- approach.md — зафиксированные правила написания документации.
4. Убрать тестовые страницы mkdocs из docs/ (docs/index.md, docs/api/).
5. make docs — проверить рендер всех страниц; итерации по ошибкам mkdocstrings.
6. pytest + pyright — зелёные; /ui/docs/ интеграция не ломается; README/STATUS обновить.
## Progress ## Progress
- [x] `scimesh/sdk/batch.py`: `MapReduceWorkload` — identity/parameters/ports + 3 научных хука; сборка манифеста, map/reduce стейджей, workflow, pinned handlers, exact-artifact verifier; хуки: domain_validate, resolved_parameters, resolved_parameters_for_plan, plan_tasks, parse/validate_partial_keys, map_stage_inputs; экспортирован из scimesh.sdk. - [x] mkdocs.yml: `docs_dir: mkdocs`, полный nav, edit_uri; тема Material + mkdocstrings (handlers.python.options, show_if_no_docstring: true).
- [x] descriptor-batch, similarity-search, similarity-graph переписаны на MapReduceWorkload; парность с локальными reference сохранена (тесты byte-identical зелёные). `query_id`-резолюция переехала в run_search_shard (ворклоад сам валидирует параметры). - [x] index.md — лендинг (быстрый старт, карта сайта).
- [x] Worker обобщён: SciMeshRunner принимает definitions+inventory+runtime, `for_worker(config)` грузит ворклоады через allowlist-дискавери (entry points) или built-in fallback; fail-closed для map-стейджей не по v1-контракту (single input); параметры таски проходят насквозь, валидация в ворклоаде. Конфиг: SCIMESH_CAPABILITIES, SCIMESH_WORKLOAD_ALLOWLIST (JSON {distribution,name,version,digest}); парсер вынесен в SDK (`workload_allowlist_from_json`). - [x] sdk/overview.md, sdk/authoring-workloads.md (полный гайд + molwt-filter пример + plan_tasks блоки), sdk/cli.md, sdk/worker-integration.md.
- [x] CLI: `scimesh workload list|run` (generic; SCIMESH_WORKLOAD_ALLOWLIST поддерживается; runtime строится из discovered-ворклоадов); зарегистрирован как ворклоад-модуль. - [x] api/: 15 страниц `::: scimesh.sdk.<module>` + api/index.md (модульная карта) — ВСЕ модули SDK.
- [x] `default_sdk_registry(allowlist=...)` и `default_sdk_runtime(workload_capabilities=..., environment_digests=...)` в library. - [x] approach.md — правила: типы страниц, reference только из docstrings (Google style), терминология, код-блоки, ссылки, когда обновлять, сборка.
- [x] Тесты: test_sdk_batch.py (5), test_cli_workload.py (6, включая end-to-end allowlisted custom workload), worker: generic execution (descriptor-batch), v1-contract rejection (graph), for_worker discovery, config parsing. - [x] Удалены тестовые страницы mkdocs из docs/ (docs/index.md, docs/api/).
- [x] Документация: workload-sdk.md (раздел "Authoring a workload" + worker/CLI), handoff, STATUS, README. - [x] Устранена проблема mkdocstrings-python 2.x: `default_options` → `handlers.python.options`; `show_if_no_docstring: true` (иначе члены без docstrings не рендерились).
- [x] Финальная верификация: 249 passed; scimesh workload list/run работают; scimesh.sdk не импортирует workloads (grep чист). - [x] Добавлены Google-style docstrings ВСЕМ публичным членам scimesh.sdk без них (~50: artifacts, manifest, plans, workflow, execution, identity, protocols, registry, resources, runtime, verification, schema, conformance); починено подавление pyright в schema.py (перенесённый форматтером `# type: ignore`).
- Изменения НЕ закоммичены (по AGENTS.md коммит только по явной просьбе). - [x] make docs --strict: 0 ошибок/предупреждений; все 16 API-страниц + 4 гайда + approach рендерятся (проверено по содержимому HTML).
- [x] pytest 256 passed; pyright scimesh/tests 0 errors.
- [x] README/STATUS/handoff обновлены (mkdocs/ сайт, /ui/docs/).
- Изменения НЕ закоммичены (коммит по запросу).
+7
View File
@@ -56,6 +56,13 @@ python3 -m venv .venv
make demo-ui make demo-ui
``` ```
The MkDocs documentation site is served inside the UI at `/ui/docs/`
(`make docs` builds it from `mkdocs/`; the demo mounts `site/`
automatically, or set `SCIMESH_DOCS_DIR` for a manual coordinator). The site
covers the complete Workload SDK: guides (`mkdocs/sdk/`), the full
auto-generated API reference for `scimesh.sdk` (`mkdocs/api/`), and the
documentation rules the site is written by (`mkdocs/approach.md`).
Open `http://localhost:18080/ui` and sign in with username `operator` and Open `http://localhost:18080/ui` and sign in with username `operator` and
password `demo-ui-secret`. The command starts PostgreSQL, the coordinator, and password `demo-ui-secret`. The command starts PostgreSQL, the coordinator, and
two local reference workers. Upload a small ChEMBL TSV, then use the job page two local reference workers. Upload a small ChEMBL TSV, then use the job page
+2 -1
View File
@@ -46,9 +46,10 @@ the complete result-artifact SHA-256 before a task is accepted.
| CTX-08 Distributed similarity-search | Implemented | Python planner resolves `query_id` once, creates deterministic shard plans, worker adapter emits exact partial top-k CSVs/metrics, and reducer matches the local reference. | | CTX-08 Distributed similarity-search | Implemented | Python planner resolves `query_id` once, creates deterministic shard plans, worker adapter emits exact partial top-k CSVs/metrics, and reducer matches the local reference. |
| CTX-09 Reducer and final-result API | Implemented | Atomic `reducing` claim, deterministic coordinator-side top-k reducer, sanitized reducer failure, final artifact persistence, `result_uri`, and final CSV download. | | CTX-09 Reducer and final-result API | Implemented | Atomic `reducing` claim, deterministic coordinator-side top-k reducer, sanitized reducer failure, final artifact persistence, `result_uri`, and final CSV download. |
| CTX-10 Distributed similarity-graph | Not started | Local reference exists; the SDK-built local graph workload already enforces the pair-coverage invariant. | | CTX-10 Distributed similarity-graph | Not started | Local reference exists; the SDK-built local graph workload already enforces the pair-coverage invariant. |
| CTX-11 Dashboard/operator view | Implemented | Protected live control room: recent-run/worker overview, real pipeline-stage visualization, shard attempts and safe failures, validated similarity-search upload, coordinator artifacts, final-result download, bounded polling, and a Workload library page rendering the embedded catalog from `scimesh workload export` (`/ui/workloads`, regenerated via `make workloads-export`). | | CTX-11 Dashboard/operator view | Implemented | Protected live control room: MkDocs documentation served at `/ui/docs/` (SCIMESH_DOCS_DIR; the demo mounts `site/` automatically), recent-run/worker overview, real pipeline-stage visualization, shard attempts and safe failures, validated similarity-search upload, coordinator artifacts, final-result download, bounded polling, a Workload library page rendering the embedded catalog from `scimesh workload export` (`/ui/workloads`, regenerated via `make workloads-export`). |
| CTX-12 Reliability, security, CI | In progress | Unit, race, PostgreSQL integration, and smoke checks exist; CI hardening remains. | | CTX-12 Reliability, security, CI | In progress | Unit, race, PostgreSQL integration, and smoke checks exist; CI hardening remains. |
| CTX-15 User Service and access control | Implemented | User/owner scoping, verified contributors, worker keys, self-service enrollment, and quorum-backed untrusted workers are merged; local Go/Python and Docker/PostgreSQL checks passed. | | CTX-15 User Service and access control | Implemented | User/owner scoping, verified contributors, worker keys, self-service enrollment, and quorum-backed untrusted workers are merged; local Go/Python and Docker/PostgreSQL checks passed. |
| MkDocs documentation site | Implemented | A standalone documentation site (`mkdocs/`, `docs_dir: mkdocs`) covering the complete Workload SDK: guides (overview, authoring workloads, CLI, worker integration), the full auto-generated API reference for all 15 `scimesh.sdk` modules (mkdocstrings), and the writing rules (`mkdocs/approach.md`). Built with `make docs`, served inside the UI at `/ui/docs/`; the project's internal `docs/` directory is not part of the site. |
| CTX-16 Workload SDK foundation | Implemented | `scimesh.sdk` provides strict immutable manifests/plans/artifacts, digest/trust-pinned tasks, typed DAGs, compatibility negotiation, verifier primitives with owner/binding-safe quorum inputs, resource eligibility/local allocation, measured package discovery, a trusted local core-batch conformance harness, and strict package discovery. Enforcing coordinator/Worker profiles remain fail-closed. | | CTX-16 Workload SDK foundation | Implemented | `scimesh.sdk` provides strict immutable manifests/plans/artifacts, digest/trust-pinned tasks, typed DAGs, compatibility negotiation, verifier primitives with owner/binding-safe quorum inputs, resource eligibility/local allocation, measured package discovery, a trusted local core-batch conformance harness, and strict package discovery. Enforcing coordinator/Worker profiles remain fail-closed. |
| SDK roadmap step 3: `descriptor-batch` | Implemented | The first SDK-built reference workload (`scimesh/workloads/descriptors/`): pinned 81-name RDKit 2D descriptor set, canonical one-row-per-input CSV, deterministic row-bounded shards, shard-index concatenation with one header, byte-identical local/distributed output, and a two-worker `untrusted_quorum` verifier test. | | SDK roadmap step 3: `descriptor-batch` | Implemented | The first SDK-built reference workload (`scimesh/workloads/descriptors/`): pinned 81-name RDKit 2D descriptor set, canonical one-row-per-input CSV, deterministic row-bounded shards, shard-index concatenation with one header, byte-identical local/distributed output, and a two-worker `untrusted_quorum` verifier test. |
| SDK-built `similarity-search` and `similarity-graph` | Implemented | Both workloads are SDK-built packages (`scimesh/workloads/search/`, `scimesh/workloads/graph/`) built on the `MapReduceWorkload` authoring scaffold (`scimesh/sdk/batch.py`); they reuse the local scientific cores and are byte-identical to the single-process references (search; graph for both threshold directions and any block size). The graph reducer enforces the CTX-10 pair-coverage invariant. `scimesh/workloads/library.py` composes the built-in registry/runtime. | | SDK-built `similarity-search` and `similarity-graph` | Implemented | Both workloads are SDK-built packages (`scimesh/workloads/search/`, `scimesh/workloads/graph/`) built on the `MapReduceWorkload` authoring scaffold (`scimesh/sdk/batch.py`); they reuse the local scientific cores and are byte-identical to the single-process references (search; graph for both threshold directions and any block size). The graph reducer enforces the CTX-10 pair-coverage invariant. `scimesh/workloads/library.py` composes the built-in registry/runtime. |
+1 -1
View File
@@ -121,7 +121,7 @@ func run() error {
// pool.Ping backs /health: readiness means the database answers, not just // pool.Ping backs /health: readiness means the database answers, not just
// that the process is alive. // that the process is alive.
api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, cfg.UserserviceURL, m, pool.Ping, cfg.PublicCoordinatorURL, cfg.PublicUserserviceURL) api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, cfg.UserserviceURL, m, pool.Ping, cfg.PublicCoordinatorURL, cfg.PublicUserserviceURL, cfg.DocsDir)
err = infra.RunServer(ctx, log, cfg.Addr, api.Handler(cfg.Token, cfg.UIToken)) err = infra.RunServer(ctx, log, cfg.Addr, api.Handler(cfg.Token, cfg.UIToken))
// Shutdown order matters, and defers alone cannot express it (they run // Shutdown order matters, and defers alone cannot express it (they run
+3
View File
@@ -51,6 +51,9 @@ services:
WORKER_AUTH_TOKEN: ${WORKER_AUTH_TOKEN:-dev-token} WORKER_AUTH_TOKEN: ${WORKER_AUTH_TOKEN:-dev-token}
# Empty disables /ui. Set this separately from the worker token. # Empty disables /ui. Set this separately from the worker token.
UI_AUTH_TOKEN: ${UI_AUTH_TOKEN:-} UI_AUTH_TOKEN: ${UI_AUTH_TOKEN:-}
# Directory of the built MkDocs site served at /ui/docs/ (empty disables
# the docs route; the demo mounts ./site automatically).
SCIMESH_DOCS_DIR: ${SCIMESH_DOCS_DIR:-}
DB_MAX_CONNS: "10" DB_MAX_CONNS: "10"
REQUEST_TIMEOUT: "15s" REQUEST_TIMEOUT: "15s"
LEASE_DURATION: "2m" LEASE_DURATION: "2m"
+4
View File
@@ -51,6 +51,9 @@ type Config struct {
LogFile string LogFile string
// Directory where artifact bytes are stored. // Directory where artifact bytes are stored.
StorageDir string StorageDir string
// Directory of the built MkDocs site (site/) served at /ui/docs/. Empty
// disables the docs route; the UI shows a hint page instead.
DocsDir string
// Upper bound on an uploaded dataset or artifact body, in bytes. // Upper bound on an uploaded dataset or artifact body, in bytes.
MaxUploadBytes int64 MaxUploadBytes int64
@@ -108,6 +111,7 @@ func LoadConfig() (Config, error) {
LogLevel: getEnv("LOG_LEVEL", "info"), LogLevel: getEnv("LOG_LEVEL", "info"),
LogFile: os.Getenv("LOG_FILE"), LogFile: os.Getenv("LOG_FILE"),
StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"), StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"),
DocsDir: os.Getenv("SCIMESH_DOCS_DIR"),
MaxUploadBytes: 1 << 30, // 1 GiB MaxUploadBytes: 1 << 30, // 1 GiB
DBMaxConns: 10, DBMaxConns: 10,
DBConnectTimeout: 30 * time.Second, DBConnectTimeout: 30 * time.Second,
@@ -56,6 +56,8 @@ type Server struct {
publicUserserviceURL string publicUserserviceURL string
// httpClient makes the login/register calls to the userservice. // httpClient makes the login/register calls to the userservice.
httpClient *http.Client httpClient *http.Client
// docsDir serves the built MkDocs site at /ui/docs/. Empty disables it.
docsDir string
// metrics holds the Prometheus registry and HTTP instrumentation. // metrics holds the Prometheus registry and HTTP instrumentation.
metrics *metrics.Metrics metrics *metrics.Metrics
// ready probes downstream dependencies (the database) for /health. Kept as // ready probes downstream dependencies (the database) for /health. Kept as
@@ -78,6 +80,10 @@ func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval
if len(publicURLs) > 1 { if len(publicURLs) > 1 {
publicUserserviceURL = strings.TrimRight(publicURLs[1], "/") publicUserserviceURL = strings.TrimRight(publicURLs[1], "/")
} }
docsDir := ""
if len(publicURLs) > 2 {
docsDir = publicURLs[2]
}
return &Server{ return &Server{
uc: uc, uc: uc,
log: log, log: log,
@@ -88,6 +94,7 @@ func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval
userserviceURL: strings.TrimRight(userserviceURL, "/"), userserviceURL: strings.TrimRight(userserviceURL, "/"),
publicCoordinatorURL: publicCoordinatorURL, publicCoordinatorURL: publicCoordinatorURL,
publicUserserviceURL: publicUserserviceURL, publicUserserviceURL: publicUserserviceURL,
docsDir: docsDir,
httpClient: &http.Client{Timeout: 10 * time.Second}, httpClient: &http.Client{Timeout: 10 * time.Second},
metrics: m, metrics: m,
ready: ready, ready: ready,
@@ -136,6 +143,8 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler {
{"GET /ui", s.handleUIHome}, {"GET /ui", s.handleUIHome},
{"GET /ui/jobs/new", s.handleUINewJob}, {"GET /ui/jobs/new", s.handleUINewJob},
{"GET /ui/workloads", s.handleUIWorkloads}, {"GET /ui/workloads", s.handleUIWorkloads},
{"GET /ui/docs", s.handleUIDocsIndex},
{"GET /ui/docs/{path...}", s.handleUIDocs},
{"GET /ui/jobs/{job_id}", s.handleUIJob}, {"GET /ui/jobs/{job_id}", s.handleUIJob},
{"GET /ui/api/overview", s.handleUIOverviewJSON}, {"GET /ui/api/overview", s.handleUIOverviewJSON},
{"GET /ui/api/jobs/{job_id}", s.handleUIJobJSON}, {"GET /ui/api/jobs/{job_id}", s.handleUIJobJSON},
@@ -13,7 +13,7 @@
<main class="page"> <main class="page">
<header class="top"> <header class="top">
<div><p class="eyebrow">Local scientific compute</p><h1 class="title">SciMesh control room</h1><p class="lead">Follow the real path from a molecular TSV to a globally reduced similarity result—without reading coordinator logs.</p><div class="live"><i class="pulse"></i><span id="refresh-state">Live overview · refreshes every 2 seconds</span></div></div> <div><p class="eyebrow">Local scientific compute</p><h1 class="title">SciMesh control room</h1><p class="lead">Follow the real path from a molecular TSV to a globally reduced similarity result—without reading coordinator logs.</p><div class="live"><i class="pulse"></i><span id="refresh-state">Live overview · refreshes every 2 seconds</span></div></div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">{{if .Session}}<span class="live" style="margin-top:0">Signed in · {{.Session.Role}}</span>{{end}}{{if .Session}}<a class="button" href="/ui/profile" style="background:#23344d;color:#dce8ff;box-shadow:none">Profile</a>{{end}}{{if and .Session (eq .Session.Role "admin")}}<a class="button" href="/ui/admin" style="background:#23344d;color:#dce8ff;box-shadow:none">Admin</a>{{end}}{{if .Session}}<a class="button" href="/ui/workloads" style="background:#23344d;color:#dce8ff;box-shadow:none">Workloads</a>{{end}}{{if .Session}}<a class="button" href="/ui/workers/new" style="background:#23344d;color:#dce8ff;box-shadow:none">🖥 Add your machine</a>{{end}}<a class="button" href="/ui/jobs/new">+ New similarity search</a>{{if .Session}}<form method="post" action="/ui/logout" style="margin:0"><button class="button" type="submit" style="background:#23344d;color:#dce8ff;box-shadow:none">Log out</button></form>{{end}}</div> <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">{{if .Session}}<span class="live" style="margin-top:0">Signed in · {{.Session.Role}}</span>{{end}}{{if .Session}}<a class="button" href="/ui/profile" style="background:#23344d;color:#dce8ff;box-shadow:none">Profile</a>{{end}}{{if and .Session (eq .Session.Role "admin")}}<a class="button" href="/ui/admin" style="background:#23344d;color:#dce8ff;box-shadow:none">Admin</a>{{end}}{{if .Session}}<a class="button" href="/ui/workloads" style="background:#23344d;color:#dce8ff;box-shadow:none">Workloads</a>{{end}}{{if .Session}}<a class="button" href="/ui/docs/" style="background:#23344d;color:#dce8ff;box-shadow:none">Docs</a>{{end}}{{if .Session}}<a class="button" href="/ui/workers/new" style="background:#23344d;color:#dce8ff;box-shadow:none">🖥 Add your machine</a>{{end}}<a class="button" href="/ui/jobs/new">+ New similarity search</a>{{if .Session}}<form method="post" action="/ui/logout" style="margin:0"><button class="button" type="submit" style="background:#23344d;color:#dce8ff;box-shadow:none">Log out</button></form>{{end}}</div>
</header> </header>
<section class="summary" aria-label="Pipeline summary"> <section class="summary" aria-label="Pipeline summary">
<div class="panel"><strong>How a search becomes a result</strong><div class="pipeline"><div class="flow-step"><span><i class="dot"></i>01</span><b>Upload TSV</b><small>The coordinator validates and slices the dataset.</small></div><div class="flow-step"><span><i class="dot"></i>02</span><b>Run shards</b><small>Workers fingerprint molecules and return shard top-k CSVs.</small></div><div class="flow-step"><span><i class="dot"></i>03</span><b>Merge exactly</b><small>The coordinator ranks retained candidates deterministically.</small></div><div class="flow-step"><span><i class="dot"></i>04</span><b>Download CSV</b><small>A checksum-protected global result is ready.</small></div></div></div> <div class="panel"><strong>How a search becomes a result</strong><div class="pipeline"><div class="flow-step"><span><i class="dot"></i>01</span><b>Upload TSV</b><small>The coordinator validates and slices the dataset.</small></div><div class="flow-step"><span><i class="dot"></i>02</span><b>Run shards</b><small>Workers fingerprint molecules and return shard top-k CSVs.</small></div><div class="flow-step"><span><i class="dot"></i>03</span><b>Merge exactly</b><small>The coordinator ranks retained candidates deterministically.</small></div><div class="flow-step"><span><i class="dot"></i>04</span><b>Download CSV</b><small>A checksum-protected global result is ready.</small></div></div></div>
@@ -0,0 +1,24 @@
{{define "docs-unavailable.html"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Documentation · SciMesh</title>
<style>
:root{color:#e5efff;background:#08111f;font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;color-scheme:dark}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 12% -8%,#183f77 0,transparent 32rem),#08111f}.page{max-width:760px;margin:auto;padding:80px 22px}a{color:#94bdff}.eyebrow{margin:0 0 4px;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:0;color:#f4f8ff;font-size:clamp(2rem,5vw,3rem);letter-spacing:-.055em}.card{margin-top:26px;border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021;padding:22px}.card p{color:#b9c9e2}code{background:#0a1626;border:1px solid #2b4a6b;border-radius:6px;padding:2px 7px;color:#b5d3f5;font-size:.88em}.back{display:inline-block;margin-top:22px;text-decoration:none}
</style>
</head>
<body>
<main class="page">
<p class="eyebrow">MkDocs site</p>
<h1>Documentation is not available</h1>
<div class="card">
<p>The documentation site has not been built or the coordinator has not been pointed at it. From the repository root, run:</p>
<p><code>make docs</code> &nbsp;then restart the coordinator with <code>SCIMESH_DOCS_DIR</code> set to the generated <code>site/</code> directory (the <code>make demo-ui</code> demo does this automatically).</p>
<a class="back" href="/ui">← Back to the control room</a>
</div>
</main>
</body>
</html>
{{end}}
@@ -0,0 +1,53 @@
package http
import (
"net/http"
"os"
"path/filepath"
"strings"
)
// handleUIDocsIndex redirects /ui/docs to the trailing-slash form so the
// wildcard route below can resolve index.html.
func (s *Server) handleUIDocsIndex(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/docs/", http.StatusPermanentRedirect)
}
// handleUIDocs serves the built MkDocs site (site/) as static files. The
// configured docs directory is an operator-supplied path, never derived from
// a request; path traversal is rejected by joining against the cleaned root
// and checking the result stays inside it.
func (s *Server) handleUIDocs(w http.ResponseWriter, r *http.Request) {
if s.docsDir == "" {
s.renderUI(w, "docs-unavailable.html", nil)
return
}
root, err := filepath.Abs(s.docsDir)
if err != nil {
s.renderUI(w, "docs-unavailable.html", nil)
return
}
clean := filepath.Clean(strings.TrimPrefix(r.URL.Path, "/ui/docs/"))
target, err := filepath.Abs(filepath.Join(root, clean))
if err != nil {
s.renderUI(w, "docs-unavailable.html", nil)
return
}
rel, err := filepath.Rel(root, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
http.NotFound(w, r)
return
}
info, err := os.Stat(target)
if err != nil || info.IsDir() {
if err == nil && info.IsDir() {
target = filepath.Join(target, "index.html")
info, err = os.Stat(target)
}
if err != nil || info.IsDir() {
s.renderUI(w, "docs-unavailable.html", nil)
return
}
}
http.ServeFile(w, r, target)
}
@@ -0,0 +1,89 @@
package http
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func docsTestServer(t *testing.T, docsDir string) *Server {
t.Helper()
return &Server{
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
docsDir: docsDir,
}
}
func TestUIDocsServesIndexAndNestedFiles(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("<h1>Home</h1>"), 0o644); err != nil {
t.Fatal(err)
}
sub := filepath.Join(root, "api")
if err := os.Mkdir(sub, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "page.html"), []byte("<h1>API page</h1>"), 0o644); err != nil {
t.Fatal(err)
}
server := docsTestServer(t, root)
index := httptest.NewRecorder()
server.handleUIDocs(index, httptest.NewRequest(http.MethodGet, "/ui/docs/", nil))
if index.Code != http.StatusOK || !strings.Contains(index.Body.String(), "<h1>Home</h1>") {
t.Fatalf("index = %d %q", index.Code, index.Body.String())
}
page := httptest.NewRecorder()
server.handleUIDocs(page, httptest.NewRequest(http.MethodGet, "/ui/docs/api/page.html", nil))
if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "<h1>API page</h1>") {
t.Fatalf("nested page = %d %q", page.Code, page.Body.String())
}
}
func TestUIDocsRejectsPathTraversal(t *testing.T) {
root := t.TempDir()
secret := filepath.Join(root, "secret.txt")
if err := os.WriteFile(secret, []byte("private"), 0o600); err != nil {
t.Fatal(err)
}
server := docsTestServer(t, root)
request := httptest.NewRequest(http.MethodGet, "/ui/docs/../secret.txt", nil)
request.URL.Path = "/ui/docs/../secret.txt"
recorder := httptest.NewRecorder()
server.handleUIDocs(recorder, request)
if recorder.Code != http.StatusNotFound {
t.Fatalf("traversal status = %d, want 404", recorder.Code)
}
}
func TestUIDocsShowsBuildHintWhenDisabledOrMissing(t *testing.T) {
disabled := docsTestServer(t, "")
recorder := httptest.NewRecorder()
disabled.handleUIDocs(recorder, httptest.NewRequest(http.MethodGet, "/ui/docs/", nil))
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "Documentation is not available") {
t.Fatalf("disabled docs = %d %q", recorder.Code, recorder.Body.String())
}
missing := docsTestServer(t, filepath.Join(t.TempDir(), "does-not-exist"))
recorder = httptest.NewRecorder()
missing.handleUIDocs(recorder, httptest.NewRequest(http.MethodGet, "/ui/docs/", nil))
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "Documentation is not available") {
t.Fatalf("missing docs = %d %q", recorder.Code, recorder.Body.String())
}
}
func TestUIDocsIndexRedirectsToTrailingSlash(t *testing.T) {
server := docsTestServer(t, t.TempDir())
recorder := httptest.NewRecorder()
server.handleUIDocsIndex(recorder, httptest.NewRequest(http.MethodGet, "/ui/docs", nil))
if recorder.Code != http.StatusPermanentRedirect || recorder.Header().Get("Location") != "/ui/docs/" {
t.Fatalf("redirect = %d %q", recorder.Code, recorder.Header().Get("Location"))
}
}
+26 -1
View File
@@ -32,6 +32,29 @@ worker_bin=${SCIMESH_WORKER_BIN:-"$repo_dir/.venv/bin/scimesh-worker"}
pid_file="$demo_dir/workers.pids" pid_file="$demo_dir/workers.pids"
logs_dir="$demo_dir/logs" logs_dir="$demo_dir/logs"
# The built MkDocs site is mounted into the demo coordinator so the UI can
# serve it at /ui/docs/. When site/ is missing (make docs), the docs route
# shows a build hint instead.
docs_compose_file="$demo_dir/docker-compose.docs.yml"
docs_compose_files=""
prepare_docs_override() {
mkdir -p "$demo_dir"
if [[ -d "$repo_dir/site" ]]; then
cat > "$docs_compose_file" <<DOCS_OVERRIDE_EOF
services:
coordinator:
volumes:
- $repo_dir/site:/site:ro
environment:
SCIMESH_DOCS_DIR: /site
DOCS_OVERRIDE_EOF
docs_compose_files="-f $docs_compose_file"
else
docs_compose_files=""
fi
}
compose() { compose() {
POSTGRES_PORT="$postgres_port" \ POSTGRES_PORT="$postgres_port" \
COORDINATOR_PORT="$coordinator_port" \ COORDINATOR_PORT="$coordinator_port" \
@@ -46,7 +69,8 @@ compose() {
docker compose -p "$project" \ docker compose -p "$project" \
-f "$coordinator_dir/docker-compose.yml" \ -f "$coordinator_dir/docker-compose.yml" \
-f "$coordinator_dir/docker-compose.users.yml" \ -f "$coordinator_dir/docker-compose.users.yml" \
-f "$coordinator_dir/docker-compose.monitoring.yml" "$@" -f "$coordinator_dir/docker-compose.monitoring.yml" \
$docs_compose_files "$@"
} }
stop_workers() { stop_workers() {
@@ -116,6 +140,7 @@ wait_for_workers() {
} }
start() { start() {
prepare_docs_override
if ! [[ "$workers" =~ ^[1-9][0-9]*$ ]]; then if ! [[ "$workers" =~ ^[1-9][0-9]*$ ]]; then
echo "DEMO_WORKERS must be a positive integer (got $workers)." >&2 echo "DEMO_WORKERS must be a positive integer (got $workers)." >&2
exit 2 exit 2
-8
View File
@@ -1,8 +0,0 @@
# SDK batch scaffold
Тестовая страница автогенерации API из docstrings.
::: scimesh.sdk.batch.MapReduceWorkload
options:
show_root_heading: true
show_source: false
-10
View File
@@ -1,10 +0,0 @@
# SciMesh
Тестовая страница MkDocs. Сборка и навигация работают; содержимое будет
наполнено позже.
- Пример ссылки на API: [SDK batch scaffold](api/sdk-batch.md).
- Код:
```python
from scimesh.sdk import MapReduceWorkload
```
+2
View File
@@ -26,6 +26,8 @@ resolves `query_id` per task and rejects plan-time `max_rows`.
**Default hooks + molwt-filter (2026-08-01):** `MapReduceWorkload` now provides default `partition_input` (row-bounded, header-preserving sharding for delimited inputs, `shard_rows` class attr) and default `reduce_partials` (`concatenate_partial_tables`, one header, byte-identical). A new built-in `molwt-filter@1.0.0` (`scimesh/workloads/molwt_filter/`) demonstrates the minimal authoring surface: only `compute_shard` is workload code. descriptor-batch dropped its now-redundant partition/reduce overrides. **Default hooks + molwt-filter (2026-08-01):** `MapReduceWorkload` now provides default `partition_input` (row-bounded, header-preserving sharding for delimited inputs, `shard_rows` class attr) and default `reduce_partials` (`concatenate_partial_tables`, one header, byte-identical). A new built-in `molwt-filter@1.0.0` (`scimesh/workloads/molwt_filter/`) demonstrates the minimal authoring surface: only `compute_shard` is workload code. descriptor-batch dropped its now-redundant partition/reduce overrides.
**MkDocs site (2026-08-02):** the standalone documentation site lives in `mkdocs/` (`docs_dir: mkdocs`) and does not use the project's `docs/` directory. It contains guides (`mkdocs/sdk/`: overview, authoring-workloads, cli, worker-integration), the full auto-generated API reference for all `scimesh.sdk` modules (`mkdocs/api/`, mkdocstrings `::: scimesh.sdk.<module>` — set `show_if_no_docstring: true`), and the writing rules (`mkdocs/approach.md`). `make docs` builds it; the UI serves it at `/ui/docs/`. All public SDK members now carry Google-style docstrings.
**Authoring scaffold (2026-08-01):** `scimesh/sdk/batch.py` adds **Authoring scaffold (2026-08-01):** `scimesh/sdk/batch.py` adds
`MapReduceWorkload` — the primary authoring surface for `core-batch-v1`. A `MapReduceWorkload` — the primary authoring surface for `core-batch-v1`. A
subclass declares identity/parameters/ports and three scientific hooks subclass declares identity/parameters/ports and three scientific hooks
+38 -6
View File
@@ -1,7 +1,9 @@
site_name: SciMesh site_name: SciMesh
site_description: Local-first distributed scientific computation for molecular workloads site_description: Local-first distributed scientific computation for molecular workloads
site_url: https://github.com/emil28092005/SciMesh
repo_url: https://github.com/emil28092005/SciMesh repo_url: https://github.com/emil28092005/SciMesh
edit_uri: blob/main/docs/ edit_uri: blob/main/mkdocs/
docs_dir: mkdocs
theme: theme:
name: material name: material
@@ -13,18 +15,24 @@ theme:
- navigation.instant - navigation.instant
- navigation.tracking - navigation.tracking
- navigation.top - navigation.top
- navigation.expand
- search.suggest - search.suggest
- content.code.copy - content.code.copy
- content.code.annotate
icon: icon:
repo: fontawesome/brands/github repo: fontawesome/brands/github
plugins: plugins:
- search - search
- mkdocstrings: - mkdocstrings:
default_options: handlers:
show_root_heading: true python:
show_symbol_type_heading: true options:
show_source: false show_root_heading: true
show_symbol_type_heading: true
show_source: false
members_order: source
show_if_no_docstring: true
markdown_extensions: markdown_extensions:
- admonition - admonition
@@ -33,11 +41,35 @@ markdown_extensions:
- pymdownx.superfences - pymdownx.superfences
- pymdownx.highlight: - pymdownx.highlight:
anchor_linenums: true anchor_linenums: true
- pymdownx.details
- pymdownx.tabbed:
alternate_style: true
nav: nav:
- Home: index.md - Home: index.md
- SDK:
- Overview: sdk/overview.md
- Authoring workloads: sdk/authoring-workloads.md
- Workload CLI: sdk/cli.md
- Worker integration: sdk/worker-integration.md
- API reference: - API reference:
- SDK batch scaffold: api/sdk-batch.md - Read me: api/index.md
- Artifacts and ports: api/sdk-artifacts.md
- Batch scaffold (MapReduceWorkload): api/sdk-batch.md
- Conformance runtime: api/sdk-conformance.md
- Execution profiles: api/sdk-execution.md
- Identities and versions: api/sdk-identity.md
- Package integrity: api/sdk-integrity.md
- Manifests: api/sdk-manifest.md
- Plans and tasks: api/sdk-plans.md
- Handler protocols: api/sdk-protocols.md
- Registry and discovery: api/sdk-registry.md
- Resources: api/sdk-resources.md
- Runtime negotiation: api/sdk-runtime.md
- Parameter schemas: api/sdk-schema.md
- Verification: api/sdk-verification.md
- Workflow DAGs: api/sdk-workflow.md
- Documentation approach: approach.md
extra: extra:
generator: false generator: false
+45
View File
@@ -0,0 +1,45 @@
# API reference
This section is **generated from docstrings** by
[`mkdocstrings`](https://mkdocstrings.github.io) — it is the complete public
API surface of `scimesh.sdk`. Markdown pages in `api/` are thin wrappers
(`::: scimesh.sdk.<module>`) and must not be hand-edited; change the code and
rebuild with `make docs`.
All value objects are frozen, recursively immutable, JSON-safe, canonically
serialized, and strict about unknown fields. Constructing them performs
full validation; invalid input raises `ValueError`.
## Module map
| Page | Module | Contents |
| --- | --- | --- |
| [Artifacts and ports](sdk-artifacts.md) | `scimesh.sdk.artifacts` | `ArtifactSchema`, `PortSpec`, `ArtifactRef`, `ArtifactCollection`, `OutputManifest`, `Provenance` |
| [Batch scaffold](sdk-batch.md) | `scimesh.sdk.batch` | `MapReduceWorkload`, `concatenate_partial_tables` |
| [Conformance runtime](sdk-conformance.md) | `scimesh.sdk.conformance` | `LocalArtifactStore`, `LocalCoreBatchExecutor`, scoped contexts, round-trip helper |
| [Execution profiles](sdk-execution.md) | `scimesh.sdk.execution` | `ExecutionProfile`, `RetryPolicy`, `CheckpointPolicy`, `FailureReport` |
| [Identities](sdk-identity.md) | `scimesh.sdk.identity` | `WorkloadId`, `VersionRange`, `SchemaRef`, `ComponentRef`, `FeatureRequirement` |
| [Package integrity](sdk-integrity.md) | `scimesh.sdk.integrity` | `installed_distribution_digest` |
| [Manifests](sdk-manifest.md) | `scimesh.sdk.manifest` | `WorkloadManifest`, `PackageSpec`, `EnvironmentSpec`, `VerifierSpec`, `WorkloadLimits`, trust/determinism enums |
| [Plans and tasks](sdk-plans.md) | `scimesh.sdk.plans` | `JobRequest`, `ValidatedJob`, `TaskSpec`, `WorkflowPlan`, `ExpansionManifest` |
| [Handler protocols](sdk-protocols.md) | `scimesh.sdk.protocols` | `Planner`, `Runner`, `Reducer`, `Verifier`, contexts, catalog/sink |
| [Registry](sdk-registry.md) | `scimesh.sdk.registry` | `WorkloadRegistry`, `WorkloadDefinition`, `AllowedPackage`, discovery |
| [Resources](sdk-resources.md) | `scimesh.sdk.resources` | `ResourceRequirements`, `ResourceInventory`, `ResourcePool`, accelerators |
| [Runtime negotiation](sdk-runtime.md) | `scimesh.sdk.runtime` | `RuntimeCapabilities`, `negotiate_manifest`, `CompatibilityError` |
| [Parameter schemas](sdk-schema.md) | `scimesh.sdk.schema` | Bounded JSON Schema subset |
| [Verification](sdk-verification.md) | `scimesh.sdk.verification` | Verifiers, decisions, bindings, candidate envelopes |
| [Workflow DAGs](sdk-workflow.md) | `scimesh.sdk.workflow` | `WorkflowSpec`, `StageSpec`, `ArtifactEdge`, advanced declarations |
## Reading the generated pages
- **Classes** show their full signature, validation rules, and public
methods; properties are listed with their type.
- **Module-level functions** (for example `negotiate_manifest`) document
their exact contract and failure modes.
- Cross-references to other SDK symbols link automatically.
To keep the reference correct:
- write docstrings in **Google style** (`Args:` / `Returns:` / `Raises:`);
- document validation failures and fail-closed behavior;
- rebuild with `make docs` after any docstring change.
+8
View File
@@ -0,0 +1,8 @@
# Artifacts and ports
Typed artifact schemas, port declarations, immutable artifact references and
collections, output manifests, and provenance.
::: scimesh.sdk.artifacts
options:
show_root_heading: false
+9
View File
@@ -0,0 +1,9 @@
# Batch scaffold
The primary authoring surface for `core-batch-v1`: `MapReduceWorkload` and
the default partial-concatenation helper. See the
[authoring guide](../sdk/authoring-workloads.md) for a full walkthrough.
::: scimesh.sdk.batch
options:
show_root_heading: false
+8
View File
@@ -0,0 +1,8 @@
# Conformance runtime
The trusted, in-process correctness harness: content-addressed artifact
store, scoped catalog/sink contexts, and `LocalCoreBatchExecutor`.
::: scimesh.sdk.conformance
options:
show_root_heading: false
+7
View File
@@ -0,0 +1,7 @@
# Execution profiles
Execution, retry, checkpoint, and failure declarations.
::: scimesh.sdk.execution
options:
show_root_heading: false
+8
View File
@@ -0,0 +1,8 @@
# Identities and versions
Versioned identities used across the SDK: workload ids, version ranges,
schema and component references, and feature requirements.
::: scimesh.sdk.identity
options:
show_root_heading: false
+7
View File
@@ -0,0 +1,7 @@
# Package integrity
Content pinning of installed distributions for allowlisted discovery.
::: scimesh.sdk.integrity
options:
show_root_heading: false
+7
View File
@@ -0,0 +1,7 @@
# Manifests
The installed-package manifest and cross-component compatibility contract.
::: scimesh.sdk.manifest
options:
show_root_heading: false
+8
View File
@@ -0,0 +1,8 @@
# Plans and tasks
Strict job, task, and workflow-plan value objects, plus dynamic expansion
manifests.
::: scimesh.sdk.plans
options:
show_root_heading: false
+8
View File
@@ -0,0 +1,8 @@
# Handler protocols
Author-facing contracts for planners, runners, reducers, verifiers, and the
bridge-owned catalog/sink/cancellation contexts.
::: scimesh.sdk.protocols
options:
show_root_heading: false
+8
View File
@@ -0,0 +1,8 @@
# Registry and discovery
Explicit, digest-pinned workload package registry and safe installed
discovery.
::: scimesh.sdk.registry
options:
show_root_heading: false
+8
View File
@@ -0,0 +1,8 @@
# Resources
Resource requirements, host inventory, atomic local reservation, and
accelerator declarations.
::: scimesh.sdk.resources
options:
show_root_heading: false
+7
View File
@@ -0,0 +1,7 @@
# Runtime negotiation
Fail-closed SDK/profile/feature/resource compatibility negotiation.
::: scimesh.sdk.runtime
options:
show_root_heading: false
+7
View File
@@ -0,0 +1,7 @@
# Parameter schemas
The bounded JSON Schema subset used for public workload parameters.
::: scimesh.sdk.schema
options:
show_root_heading: false
+8
View File
@@ -0,0 +1,8 @@
# Verification
Verifier primitives, decisions, coordinator bindings, and candidate
envelopes for quorum acceptance.
::: scimesh.sdk.verification
options:
show_root_heading: false
+8
View File
@@ -0,0 +1,8 @@
# Workflow DAGs
Versioned workflow graphs, stage declarations, artifact edges, and bounded
advanced-stage declarations (loops, streams, gangs, side effects).
::: scimesh.sdk.workflow
options:
show_root_heading: false
+93
View File
@@ -0,0 +1,93 @@
# Documentation approach
These rules are the contract for this site. Every page must follow them;
reviewers enforce them.
## 1. Purpose and scope
This MkDocs site documents **the Workload SDK**: how to use it, how to
author workloads, and the complete `scimesh.sdk` API. It does **not** host
the project's internal documentation (contracts, task briefs, planning
documents) — those live in the repository's `docs/` directory and are not
part of the site. Where a guide needs a contract detail, link to the
repository file in prose; do not copy its content.
## 2. Page types and status markers
Every page starts with one of:
- **concept** — explains a model (for example the SDK overview);
- **guide** — how to accomplish a task (authoring workloads, CLI, worker);
- **reference** — generated API documentation, never hand-written.
Guides may be marked with an admonition when a section describes future or
fail-closed behavior:
```markdown
!!! warning "Not yet supported"
The v1 coordinator contract does not persist resource requirements...
```
## 3. The reference is generated, not written
- `api/` pages contain only mkdocstrings directives
(`::: scimesh.sdk.<module>`); editing them by hand is an error.
- Public API must be documented in **Google-style docstrings** in the code:
`Args:`, `Returns:`, `Raises:`.
- Every validation failure and fail-closed path must be documented in the
docstring.
- After any docstring change, rebuild: `make docs`.
## 4. Terminology
Use the single glossary from `PLAN.md`:
| Term | Meaning |
| --- | --- |
| Job / Run | A user-requested full computation |
| Task | One independently executable unit of a job |
| Attempt | A lease for one task execution |
| Artifact | A durable input, shard, partial, or final result |
| Workload | A user script (package) built on the SDK |
Never introduce synonyms (`pipeline`, `run` for task, etc.). The word
"workload" in this site means an SDK workload (a user script), not a
"workload" in the performance sense.
## 5. Code and output conventions
- Use language-tagged fenced blocks: ```python, ```bash, ```text, ```json.
- Never include local machine paths, tokens, or private data in examples.
- Show complete runnable examples; prefer the real built-in workloads
(`molwt-filter`, `descriptor-batch`) over invented ones.
- Keep command output minimal and accurate; regenerate it, don't retype it.
## 6. Linking
- Relative links inside `mkdocs/` (for example `../api/sdk-batch.md`).
- Repository files outside the site (`docs/`, `PLAN.md`) are referenced in
prose with their path, not linked as site pages.
- Every guide must link to the relevant API pages.
## 7. When to write or update
- **New workload** → update `sdk/authoring-workloads.md` examples and the
UI workload catalog (`make workloads-export`).
- **SDK API change** → update docstrings; the reference rebuilds.
- **Worker/coordinator behavior change** → update
`sdk/worker-integration.md` and the fail-closed warnings.
- **New CLI surface** → update `sdk/cli.md`.
- Behavior changes without documentation updates are incomplete changes.
## 8. Build and verification
```bash
make docs # build into site/
make docs-serve # http://localhost:8000
```
- `mkdocs build` must succeed with no errors.
- New or changed pages must render (check the generated HTML, not just the
markdown).
- The site is served inside the coordinator UI at `/ui/docs/`; the demo
mounts `site/` automatically.
+86
View File
@@ -0,0 +1,86 @@
# SciMesh
SciMesh is a local-first platform for scientific computation on molecular
datasets. It turns a scientific run into independent tasks, dispatches them
to Python workers, and deterministically combines the partial results into a
checksum-protected final artifact.
The two halves of the project:
- **The Workload SDK (`scimesh.sdk`)** — a strict Python framework for
authoring scientific workloads. Workloads are ordinary user scripts built
on the SDK; they run locally, in the conformance harness, and on claimed
coordinator tasks without touching any other part of the program.
- **The coordinator and worker** — a Go/PostgreSQL coordinator with an
operator UI and Python worker agents that execute SDK-built workloads over
an HTTP contract.
## What is implemented
- **SDK-built workloads**: `similarity-search` (exact top-k Tanimoto search),
`similarity-graph` (exact sparse similarity graph with pair-coverage),
`descriptor-batch` (pinned RDKit 2D descriptors), and `molwt-filter`
(molecular-weight filter — the minimal authoring example).
- **`MapReduceWorkload`**: the primary authoring scaffold. A subclass
declares identity, parameters, ports, and scientific hooks; the SDK
assembles the manifest, map/reduce stages, the digest-pinned
planner/runner/reducer, and the exact-artifact verifier.
- **A local conformance runtime** (`LocalCoreBatchExecutor`): a trusted,
in-process harness that validates scientific parity, sealed outputs,
provenance, and limits.
- **A distributed worker** that executes the same SDK workload handlers on
tasks claimed from the coordinator, with digest-pinned `TaskSpec`s,
resource reservation, and allowlist-driven workload discovery.
- **An operator UI** served by the coordinator: the control room, a workload
library page, and this documentation site at `/ui/docs/`.
## Quick start
```bash
python -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'
```
List the installed SDK workloads and run one locally:
```bash
scimesh workload list
scimesh workload run molwt-filter \
--input molecules.tsv \
--params '{"min_molwt": 40.0}' \
-o filtered.csv
```
Run the local scientific CLI workloads:
```bash
scimesh help
```
Start the full demo (PostgreSQL, coordinator, UI, two workers):
```bash
make demo-ui
# open http://localhost:18080/ui (root@scimesh.local / rootpassword)
```
Build and serve this documentation site:
```bash
make docs
make docs-serve # http://localhost:8000
```
## Where to go next
- [SDK overview](sdk/overview.md) — what the SDK is and is not.
- [Authoring workloads](sdk/authoring-workloads.md) — write your first
workload with `MapReduceWorkload`.
- [Workload CLI](sdk/cli.md) — list, run, and export workloads from the
command line.
- [Worker integration](sdk/worker-integration.md) — how the distributed
worker executes SDK workloads.
- [API reference](api/index.md) — the complete `scimesh.sdk` API, generated
from docstrings.
- [Documentation approach](approach.md) — the rules this site is written by.
+262
View File
@@ -0,0 +1,262 @@
# Authoring workloads
A workload is a **user script** that imports the SDK. For the standard
`core-batch-v1` shape — one input dataset, deterministic shards, one merged
result — subclass [`MapReduceWorkload`](../api/sdk-batch.md) and implement
the scientific hooks. The SDK assembles everything else: the immutable
manifest, the map/reduce stages, the workflow DAG, the digest-pinned
planner/runner/reducer handlers, and the exact-artifact verifier.
## The minimal workload
This is the complete `molwt-filter` workload (the built-in minimal example):
it filters molecules by exact RDKit molecular weight and needs only **one**
scientific hook, because the scaffold's default sharding and concatenation
already cover partitioning and reduction.
```python
from pathlib import Path
from typing import Any, Mapping
from scimesh.sdk import (
ArtifactSchema,
ComponentRef,
MapReduceWorkload,
PortSpec,
SchemaRef,
WorkloadId,
)
from scimesh.sdk.registry import WorkloadDefinition
class MolwtFilterWorkload(MapReduceWorkload):
workload_id = WorkloadId("molwt-filter", "1.0.0")
description = (
"Filter molecules by exact RDKit molecular weight, one canonical "
"CSV row per kept input molecule, in deterministic input order."
)
parameters_schema = {
"type": "object",
"additionalProperties": False,
"properties": {
"min_molwt": {"type": "number", "minimum": 0},
"max_molwt": {"type": "number", "minimum": 0},
"skip_invalid": {"type": "boolean", "default": True},
},
}
input_port = PortSpec(ArtifactSchema(
SchemaRef("molecule-table", 1),
"text/tab-separated-values",
"utf-8",
max_bytes=10 * 1024 * 1024 * 1024,
validator=ComponentRef("delimited-table", 1),
validator_configuration={"required_columns": ["canonical_smiles", "chembl_id"]},
max_records=100_000_000,
canonicalizer="scimesh-tsv-v1",
))
partial_port = output_port = PortSpec(ArtifactSchema(
SchemaRef("molwt-filtered-table", 1),
"text/csv",
"utf-8",
max_bytes=100 * 1024 * 1024 * 1024,
validator=ComponentRef("delimited-table", 1),
validator_configuration={"columns": ["chembl_id", "canonical_smiles", "molwt"]},
max_records=100_000_000,
canonicalizer="molwt-filtered-table-v1",
))
map_parameter_names = ("min_molwt", "max_molwt", "skip_invalid")
def domain_validate(self, parameters: Mapping[str, Any]) -> None:
if parameters.get("min_molwt") is None and parameters.get("max_molwt") is None:
raise ValueError("at least one of min_molwt or max_molwt is required")
def compute_shard(self, inputs, parameters, output_path):
# `inputs` maps every map-stage input port to a materialized file;
# here there is one port: "input".
return filter_molecules_by_molwt(
inputs["input"],
output_path,
min_molwt=parameters.get("min_molwt"),
max_molwt=parameters.get("max_molwt"),
skip_invalid=parameters.get("skip_invalid", True),
)
```
Because the scaffold provides default `partition_input` (row-bounded shards
that keep the header, `shard_rows` rows each) and default `reduce_partials`
(concatenation with one header), no further code is required.
## Required class attributes
| Attribute | Meaning |
| --- | --- |
| `workload_id` | `WorkloadId("name", "1.0.0")` — the immutable identity |
| `description` | Shown in `scimesh workload list`, the UI library, and the catalog export |
| `parameters_schema` | Strict JSON object schema (`additionalProperties: false`); the registry validates jobs against it before the planner runs |
| `input_port` | External input port (`PortSpec`) |
| `partial_port` | One map output artifact (`PortSpec`) |
| `output_port` | Final result artifact (`PortSpec`) |
## Optional class attributes
| Attribute | Default | Meaning |
| --- | --- | --- |
| `map_stage_inputs` | `{"input": input_port}` | Map-stage input ports; extra ports must share the external input schema |
| `map_parameter_names` | `()` | Parameter projection for map tasks |
| `reduce_parameter_names` | `map_parameter_names` | Parameter projection for the reducer |
| `capabilities` | `(workload_id.name,)` | Advertised capabilities |
| `trust_modes` | `(trusted, untrusted_quorum)` | Declared trust modes |
| `workflow_id` | `"<name>-map-reduce-v1"` | Workflow identity |
| `limits` | derived from port bounds | `WorkloadLimits` |
| `resources` / `execution` | CPU-1 core defaults | Per-task resource and execution profile |
| `shard_rows` | `1000` | Rows per shard for the default `partition_input` |
| `map_entry_point` / `reduce_entry_point` | derived from the module | Handler keys (can stay default) |
## Scientific hooks
Override only what your workload needs:
| Hook | Default | Purpose |
| --- | --- | --- |
| `domain_validate(parameters)` | none | Extra job-parameter validation (the JSON schema already ran) |
| `resolved_parameters(request)` | `dict(request.parameters)` | Values persisted into the plan |
| `resolved_parameters_for_plan(job, input_path, resolved)` | unchanged | Plan-time enrichment (e.g. one-time query resolution) |
| `partition_input(input_path, parameters, workspace)` | row-bounded sharding | Deterministic shard files, one per map task |
| `plan_tasks(shard_paths, resolved, job, negotiated, map_stage, context)` | one task per shard | Custom task construction |
| `task_parameters(resolved)` | filtered projection | Map-task parameters |
| `compute_shard(inputs, parameters, output_path)` | **required** | One map task; returns metrics |
| `parse_partial_key(key)` / `validate_partial_keys(parsed)` | `map.<8-digit>`, contiguous | Partial-key policy for the reducer |
| `reduce_partials(partial_paths, parameters, output_path)` | header-preserving concatenation | Deterministic merge |
Hooks must be **deterministic**: identical inputs and parameters must
produce byte-identical partials, in any worker, in any completion order.
Floats should be formatted with a fixed precision (for example `f"{v:.6f}"`),
and output row order must be canonical.
## Running a workload locally
```python
from scimesh.sdk import (
ArtifactCollection,
JobRequest,
LocalArtifactStore,
LocalCoreBatchExecutor,
WorkloadRegistry,
)
from scimesh.workloads.library import default_sdk_runtime
workload = MolwtFilterWorkload(
package_digest=current_scimesh_package_digest(),
environment_digest=current_environment_digest(),
)
registry = WorkloadRegistry()
registry.register(workload.definition(), enabled=True)
store = LocalArtifactStore(Path("artifacts"))
artifact = store.import_file(
Path("molecules.tsv"),
declaration=workload.manifest.inputs["input"].schema,
)
request = JobRequest(
workload=workload.manifest.workload,
parameters={"min_molwt": 40.0},
inputs={"input": ArtifactCollection.single(artifact)},
)
result = LocalCoreBatchExecutor(
registry, default_sdk_runtime(), store, Path("work"),
).execute(request, workload.manifest.package.digest)
print(store.materialize(result.outputs["result"].items[0].artifact).read_text())
```
`LocalCoreBatchExecutor` runs the full pipeline — negotiation, planning,
map tasks, stage verification, reduce, final verification — in-process. It
is a correctness harness, not an isolation boundary: it accepts only
trusted, single-threaded, trusted-network profiles and rejects everything
else before a handler runs.
## Custom planning: block pairs
Workloads that need more than one input per task override `plan_tasks` and
`map_stage_inputs`. The built-in `similarity-graph` plans one task per block
pair `(i, j)` with `i <= j`:
```python
map_stage_inputs = {"left": block_port, "right": block_port}
def plan_tasks(self, shard_paths, resolved, job, negotiated, map_stage, context):
block_refs = [
context.sink.seal(path, declaration=self.input_port.schema)
for path in shard_paths
]
tasks = []
for left in range(len(block_refs)):
for right in range(left, len(block_refs)):
tasks.append(self.task_spec(
map_stage, job, negotiated,
f"map/{left:04d}x{right:04d}",
{"left_block": left, "right_block": right,
"threshold": resolved["threshold"]},
{"left": ArtifactCollection.single(block_refs[left]),
"right": ArtifactCollection.single(block_refs[right])},
))
return tasks
```
Its reducer overrides `parse_partial_key`/`validate_partial_keys` to parse
`map.<i>x<j>` keys and enforce the pair-coverage invariant (every unordered
molecule pair compared exactly once).
## Packaging and discovery
Workloads are installed as part of a Python distribution and declared as
entry points:
```toml
[project.entry-points."scimesh.workloads"]
"my-workload@1.0.0" = "my_package.workload:workload_definition"
```
The factory returns a `WorkloadDefinition` (or a `MapReduceWorkload`
instance with a `definition()` method). An administrator then supplies an
`AllowedPackage(distribution, WorkloadId, "sha256:...")` allowlist entry;
discovery loads the entry point only when the installed package content
matches the pinned digest.
```python
from scimesh.sdk import AllowedPackage, WorkloadId, WorkloadRegistry
registry = WorkloadRegistry()
registry.discover_installed((
AllowedPackage("my-dist", WorkloadId("my-workload", "1.0.0"), "sha256:" + "a" * 64),
))
```
## Tests and golden parity
Add a regression test for every behavioral change:
- **Byte parity**: run the workload through `LocalCoreBatchExecutor` and
compare the final artifact bytes with a single-process reference computed
by the scientific core directly.
- **Determinism**: planning twice must produce identical JSON; results must
be invariant to shard/block sizes.
- **Fail-closed**: invalid parameters, missing ports, forged outputs, and
unsupported trust modes must be rejected.
- **Verifier policy**: for `untrusted_quorum`, two distinct owners with
identical outputs must be accepted, conflicting outputs rejected.
Use small TSV fixtures — never the full ChEMBL extract, which takes minutes
even for one shard.
## Rules
1. Keep the scientific core callable without a coordinator.
2. Inline a strict JSON parameter schema (`additionalProperties: false`);
the planner still performs domain validation.
3. Give every external and stage port an `ArtifactSchema` with bounds.
4. Return only sink-sealed artifacts in `OutputManifest`; the harness binds
task key and provenance itself.
5. Select a verifier compatible with determinism and trust: v1 permits
`untrusted_quorum` only for `byte_exact` plus `exact-artifact@1`.
6. Never put a filesystem path or transport URL into a plan or task.
+76
View File
@@ -0,0 +1,76 @@
# Workload CLI
`scimesh workload` is a generic SDK tool: it contains no workload-specific
logic, so new workloads never require changes to the CLI or any other part
of the program.
```text
scimesh workload list|run|export
```
## list
Show every installed and enabled SDK workload:
```bash
scimesh workload list
```
Output: `name version description [enabled <digest-prefix>]`. With
`SCIMESH_WORKLOAD_ALLOWLIST` set, allowlisted installed workloads are shown
instead of (or in addition to) the built-ins.
## run
Execute one workload locally against an input file:
```bash
scimesh workload run molwt-filter \
--input molecules.tsv \
--params '{"min_molwt": 40.0}' \
--shard-rows 1000 \
-o filtered.csv
```
| Option | Meaning |
| --- | --- |
| `name` | Workload name, for example `descriptor-batch` |
| `--version` | Exact workload version (default: the enabled one) |
| `--input FILE` | Input dataset file |
| `--params JSON` | Job parameters as a JSON object |
| `--shard-rows N` | Rows per planned shard (default 10000) |
| `-o, --output FILE` | Output path for the final artifact |
| `--work-dir DIR` | Working directory (default: a fresh temporary directory) |
```bash
scimesh workload run similarity-search \
--input molecules.tsv \
--params '{"query_smiles": "CCO", "top_k": 20, "progress_every": 0}'
```
The runner prints the saved path and the final metrics.
## export
Write the workload library as a JSON catalog — the same catalog the
coordinator UI embeds on its **Workloads** page:
```bash
scimesh workload export -o workloads.json
```
Regenerate the coordinator's embedded catalog with:
```bash
make workloads-export
```
## Environment
| Variable | Meaning |
| --- | --- |
| `SCIMESH_WORKLOAD_ALLOWLIST` | JSON array of `{distribution, name, version, digest}` entries; discovery loads the matching installed `scimesh.workloads` entry points |
| `SCIMESH_CAPABILITIES` | Comma-separated capabilities the worker advertises (default `similarity-search,similarity_search`) |
Both variables are read by the worker (`scimesh-worker`) and the workload
CLI.
+90
View File
@@ -0,0 +1,90 @@
# SDK overview
`scimesh.sdk` is the **framework only**. It contains no scientific workload
code. Workloads are user Python scripts and packages that import the SDK and
live outside it — the built-in SciMesh workloads under `scimesh/workloads/`
are exactly such scripts, and a future workload library can follow the same
shape.
```text
scientific implementation -> SDK manifest/plan -> map tasks -> reduce -> verifier
```
## The `core-batch-v1` profile
The implemented authoring profile is **`core-batch-v1`**: a static
map/reduce workflow with one external input and one final output.
- one input dataset (a delimited table, typically TSV);
- deterministic row-bounded (or otherwise partitioned) shards;
- one **map** task per shard, each producing one partial artifact;
- one **reduce** task merging the accepted partials into the final artifact;
- an acceptance **verifier** for every output-producing stage.
The SDK's strict value objects make the whole contract explicit:
- `WorkloadManifest` pins identity, compatibility ranges, package and
environment digests, parameter schema, workflow, ports, determinism,
trust modes, verifier, and limits;
- `WorkflowSpec`/`StageSpec` describe a typed acyclic DAG;
- `TaskSpec`/`WorkflowPlan` carry the exact workload pin (package, manifest,
environment digests, trust mode, negotiated features);
- `OutputManifest`/`Provenance` describe sealed durable results;
- artifacts are content-addressed (`sha256`), immutable, and free of
transport URLs and local paths.
## What the SDK provides
| Area | Modules | Purpose |
| --- | --- | --- |
| Identity | `identity`, `schema` | `WorkloadId`, versions, schema refs, bounded JSON parameter schemas |
| Declarations | `manifest`, `workflow`, `artifacts`, `execution`, `resources` | Manifest, DAG stages, typed ports, execution/resource profiles |
| Planning | `plans` | `JobRequest`, `TaskSpec`, `WorkflowPlan` |
| Registry | `registry`, `integrity` | Allowlisted discovery, digest pinning |
| Negotiation | `runtime` | Fail-closed compatibility negotiation |
| Execution | `conformance` | `LocalCoreBatchExecutor`, `LocalArtifactStore` |
| Verification | `verification` | Exact, canonical, and numeric verifier primitives |
| Authoring | `batch` | `MapReduceWorkload` scaffold |
## Security model
- Workload discovery requires an **administrator allowlist**: exact
distribution, workload name/version, and a `sha256:` package digest.
Discovery measures the installed package before and after importing the
entry point and fails transactionally on any mismatch. Job parameters can
never name a module, entry point, or executable.
- Compatibility negotiation is **fail-closed**: if the runtime does not
advertise a declared feature (gangs, GPU, streams, checkpoints, secrets,
retries, process pools, dynamic expansion), the job is rejected before any
workload code runs. Declaring an advanced profile never silently enables
it.
- Handlers receive **bridge-owned contexts**: `ArtifactCatalog` for verified
input materialization and `ArtifactSink` for sealing outputs. They never
see database credentials, upload URLs, or coordinator tokens.
- The **local conformance executor** is deliberately trusted and in-process.
It rejects anything but `TrustMode.TRUSTED`, a single non-nested host
thread, and the trusted network policy — a contract, not a limitation.
## What is not supported yet
The coordinator contract (v1) persists flat one-input/one-result tasks.
Until a versioned protocol rollout lands, the following remain **fail-closed
by design**:
- distributed execution of multi-input map stages (for example the
block-pair `similarity-graph` tasks) and of workloads beyond the v1
contract;
- coordinator-backed GPU scheduling, streams, gang leases, checkpoints,
retries, and secret injection;
- dynamic (plan-stage) expansion.
The verifier primitives `CanonicalRecordVerifier` and
`NumericToleranceVerifier` exist and are tested, but only the
`ExactArtifactVerifier` (whole-file SHA-256) is eligible for
`untrusted_quorum` in v1.
## Next
- [Authoring workloads](authoring-workloads.md) — build a workload with
`MapReduceWorkload`.
- [API reference](../api/index.md) — the complete SDK surface.
+82
View File
@@ -0,0 +1,82 @@
# Worker integration
The Worker Agent (`scimesh-worker`) is a coordinator client, never a
database client. It polls the coordinator over HTTP, executes SDK-built
workloads, and uploads partial results through the coordinator — results
never carry `file://` or `worker://` URIs, and failures go to `/failure`.
The same scientific handlers run in three places: the local CLI cores, the
`LocalCoreBatchExecutor` conformance harness, and the worker — because the
worker executes the workload's own SDK runner.
## Claim lifecycle
```text
register -> claim (one task) -> download input + verify sha256
-> run via SDK bridge -> upload partial -> submit result
```
- **Register**: the worker advertises its capabilities (`similarity-search`
by default; extend with `SCIMESH_CAPABILITIES`).
- **Claim**: atomic lease of one task; `204` means idle.
- **Download**: the input is streamed and its SHA-256 verified; the bearer
token is stripped on cross-origin redirects.
- **Heartbeat**: a background thread renews the lease from the returned
deadline at less than half the remaining TTL.
- **Upload**: the partial CSV is streamed to the coordinator with
`X-Worker-ID` / `X-Task-Attempt` headers, then the completion is submitted
referencing the coordinator-owned artifact id.
- **Failure**: sanitized `error_code` + message (≤300 chars, no local
paths, no tracebacks); transient transport errors are retried.
## The SDK execution bridge
`scimesh/worker/runners.py` is workload-generic. For a claimed task it:
1. normalizes the workload name (underscores → hyphens) and looks up the
loaded definition;
2. runs compatibility negotiation against a runtime derived from the loaded
definitions (capabilities + pinned environment digests) and the worker
inventory (CPU/memory from configuration);
3. verifies the workload's map stage fits the v1 contract — a single
`input` port and a single `partial` output — otherwise it fails closed
with a clear message;
4. imports the downloaded input into a content-addressed local store;
5. builds a digest-pinned `TaskSpec` (package/manifest/environment digests,
trust mode, negotiated features, stage resources and execution profile);
6. reserves resources through `ResourcePool` and runs the workload's own
`Runner` with a `LocalTaskContext` (scoped catalog/sink, provenance,
cancellation flag);
7. validates the returned `OutputManifest` (task key, provenance, sealed
vs. declared artifacts, byte budget) and returns the sealed partial for
upload.
Scientific policy lives in the workload: `query_id` resolution, parameter
validation, and `max_rows` rejection are all handled by the workload's own
hooks — the bridge passes task parameters through unchanged.
## Loading workloads
The worker loads workloads from `SCIMESH_WORKLOAD_ALLOWLIST` (a JSON array
of `{distribution, name, version, digest}` entries matched against installed
`scimesh.workloads` entry points). Discovery measures the installed package
before and after importing and fails transactionally on any mismatch. When
no allowlist is configured, the worker falls back to the built-in
`similarity-search`.
```bash
SCIMESH_WORKLOAD_ALLOWLIST='[{"distribution": "scimesh",
"name": "descriptor-batch", "version": "1.0.0",
"digest": "sha256:..."}]' scimesh-worker --coordinator-url https://...
```
## v1 contract limits
The coordinator protocol v1 persists flat one-input/one-result tasks. Until
a versioned protocol rollout:
- map stages with more than one input port (for example
`similarity-graph`'s block pairs) are **rejected by the bridge** — the
coordinator does not create such tasks anyway;
- `max_rows` is a plan-time option and is rejected per task;
- workloads beyond the allowlisted set are rejected as unsupported.
+7
View File
@@ -34,3 +34,10 @@ namespaces = false
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
[tool.pyright]
venvPath = "."
venv = ".venv"
pythonVersion = "3.10"
typeCheckingMode = "basic"
exclude = ["SciMesh", "site", "coordinator"]
+1 -1
View File
@@ -14,7 +14,7 @@ ID_COLUMN = "chembl_id"
SMILES_COLUMN = "canonical_smiles" SMILES_COLUMN = "canonical_smiles"
# Invalid records are expected in large datasets; suppress one RDKit error per row. # Invalid records are expected in large datasets; suppress one RDKit error per row.
RDLogger.DisableLog("rdApp.error") RDLogger.DisableLog("rdApp.error") # type: ignore[attr-defined]
@dataclass @dataclass
+1
View File
@@ -17,3 +17,4 @@ class Workload(Protocol):
def run(self, args: argparse.Namespace) -> int: def run(self, args: argparse.Namespace) -> int:
"""Execute the workload.""" """Execute the workload."""
...
+2 -1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import json import json
import math import math
import re import re
from enum import Enum
from types import MappingProxyType from types import MappingProxyType
from typing import Any, Mapping from typing import Any, Mapping
from urllib.parse import unquote from urllib.parse import unquote
@@ -372,7 +373,7 @@ def version_in_range(version: object, expression: str) -> bool:
return True return True
def enum_value(enum_type: type[Any], value: object, field: str) -> Any: def enum_value(enum_type: type[Enum], value: object, field: str) -> Any:
try: try:
return enum_type(value) return enum_type(value)
except (TypeError, ValueError) as error: except (TypeError, ValueError) as error:
+291 -70
View File
@@ -32,6 +32,13 @@ from .identity import ComponentRef, OUTPUT_SCHEMA_VERSION, SchemaRef, WorkloadId
class CollectionKind(str, Enum): class CollectionKind(str, Enum):
"""How a set of artifacts is structured on a port.
``SINGLE`` holds at most one unkeyed artifact; ``ORDERED`` and ``SET``
hold unkeyed artifacts with a canonical order; ``KEYED`` holds artifacts
with unique string keys.
"""
SINGLE = "single" SINGLE = "single"
ORDERED = "ordered" ORDERED = "ordered"
KEYED = "keyed" KEYED = "keyed"
@@ -39,6 +46,12 @@ class CollectionKind(str, Enum):
class Cardinality(str, Enum): class Cardinality(str, Enum):
"""How many artifacts a port accepts.
``ONE`` requires exactly one artifact, ``OPTIONAL`` at most one, and
``MANY`` at least one (with an ordered, keyed, or set collection).
"""
ONE = "one" ONE = "one"
OPTIONAL = "optional" OPTIONAL = "optional"
MANY = "many" MANY = "many"
@@ -65,12 +78,22 @@ class ArtifactSchema:
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not isinstance(self.ref, SchemaRef): if not isinstance(self.ref, SchemaRef):
raise ValueError("artifact schema ref must be a SchemaRef") raise ValueError("artifact schema ref must be a SchemaRef")
object.__setattr__(self, "media_type", require_string(self.media_type, "media_type", max_length=128)) object.__setattr__(
if "/" not in self.media_type or any(character.isspace() for character in self.media_type): self,
"media_type",
require_string(self.media_type, "media_type", max_length=128),
)
if "/" not in self.media_type or any(
character.isspace() for character in self.media_type
):
raise ValueError("media_type must be a valid type/subtype token") raise ValueError("media_type must be a valid type/subtype token")
if self.encoding is not None: if self.encoding is not None:
object.__setattr__(self, "encoding", require_identifier(self.encoding, "encoding")) object.__setattr__(
object.__setattr__(self, "max_bytes", require_positive_int(self.max_bytes, "max_bytes")) self, "encoding", require_identifier(self.encoding, "encoding")
)
object.__setattr__(
self, "max_bytes", require_positive_int(self.max_bytes, "max_bytes")
)
if not isinstance(self.validator, ComponentRef): if not isinstance(self.validator, ComponentRef):
raise ValueError("artifact schema validator must be a ComponentRef") raise ValueError("artifact schema validator must be a ComponentRef")
object.__setattr__( object.__setattr__(
@@ -83,7 +106,11 @@ class ArtifactSchema:
), ),
) )
if self.max_records is not None: if self.max_records is not None:
object.__setattr__(self, "max_records", require_positive_int(self.max_records, "max_records")) object.__setattr__(
self,
"max_records",
require_positive_int(self.max_records, "max_records"),
)
dimensions = tuple(self.max_dimensions) dimensions = tuple(self.max_dimensions)
if any( if any(
isinstance(value, bool) or not isinstance(value, int) or value < 1 isinstance(value, bool) or not isinstance(value, int) or value < 1
@@ -99,9 +126,19 @@ class ArtifactSchema:
"canonicalizer", "canonicalizer",
require_identifier(self.canonicalizer, "canonicalizer"), require_identifier(self.canonicalizer, "canonicalizer"),
) )
object.__setattr__(self, "privacy_class", require_identifier(self.privacy_class, "privacy_class")) object.__setattr__(
object.__setattr__(self, "retention_class", require_identifier(self.retention_class, "retention_class")) self,
if not isinstance(self.streaming, bool) or not isinstance(self.allow_nested_collections, bool): "privacy_class",
require_identifier(self.privacy_class, "privacy_class"),
)
object.__setattr__(
self,
"retention_class",
require_identifier(self.retention_class, "retention_class"),
)
if not isinstance(self.streaming, bool) or not isinstance(
self.allow_nested_collections, bool
):
raise ValueError("streaming and allow_nested_collections must be booleans") raise ValueError("streaming and allow_nested_collections must be booleans")
def to_dict(self) -> dict[str, object]: def to_dict(self) -> dict[str, object]:
@@ -126,10 +163,19 @@ class ArtifactSchema:
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise ValueError("artifact schema must be an object") raise ValueError("artifact schema must be an object")
fields = { fields = {
"ref", "media_type", "encoding", "max_bytes", "validator", "ref",
"validator_configuration", "max_records", "media_type",
"max_dimensions", "streaming", "canonicalizer", "privacy_class", "encoding",
"retention_class", "allow_nested_collections", "max_bytes",
"validator",
"validator_configuration",
"max_records",
"max_dimensions",
"streaming",
"canonicalizer",
"privacy_class",
"retention_class",
"allow_nested_collections",
} }
require_exact_keys(value, fields, "artifact schema") require_exact_keys(value, fields, "artifact schema")
dimensions = value["max_dimensions"] dimensions = value["max_dimensions"]
@@ -154,6 +200,13 @@ class ArtifactSchema:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class PortSpec: class PortSpec:
"""A typed artifact port: schema, cardinality, and collection kind.
``validate_collection`` enforces the declared shape against an
``ArtifactCollection``, including per-artifact schema, media type, byte,
record, and dimension bounds.
"""
schema: ArtifactSchema schema: ArtifactSchema
cardinality: Cardinality = Cardinality.ONE cardinality: Cardinality = Cardinality.ONE
collection: CollectionKind = CollectionKind.SINGLE collection: CollectionKind = CollectionKind.SINGLE
@@ -161,14 +214,32 @@ class PortSpec:
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not isinstance(self.schema, ArtifactSchema): if not isinstance(self.schema, ArtifactSchema):
raise ValueError("port schema must be an ArtifactSchema") raise ValueError("port schema must be an ArtifactSchema")
object.__setattr__(self, "cardinality", enum_value(Cardinality, self.cardinality, "cardinality")) object.__setattr__(
object.__setattr__(self, "collection", enum_value(CollectionKind, self.collection, "collection")) self,
if self.cardinality is Cardinality.MANY and self.collection is CollectionKind.SINGLE: "cardinality",
raise ValueError("many cardinality requires an ordered, keyed, or set collection") enum_value(Cardinality, self.cardinality, "cardinality"),
if self.cardinality is not Cardinality.MANY and self.collection is not CollectionKind.SINGLE: )
object.__setattr__(
self,
"collection",
enum_value(CollectionKind, self.collection, "collection"),
)
if (
self.cardinality is Cardinality.MANY
and self.collection is CollectionKind.SINGLE
):
raise ValueError(
"many cardinality requires an ordered, keyed, or set collection"
)
if (
self.cardinality is not Cardinality.MANY
and self.collection is not CollectionKind.SINGLE
):
raise ValueError("one and optional cardinality require a single collection") raise ValueError("one and optional cardinality require a single collection")
def validate_collection(self, value: "ArtifactCollection", field: str = "artifact collection") -> None: def validate_collection(
self, value: "ArtifactCollection", field: str = "artifact collection"
) -> None:
if value.kind is not self.collection: if value.kind is not self.collection:
raise ValueError(f"{field} kind does not match its port declaration") raise ValueError(f"{field} kind does not match its port declaration")
count = len(value.items) count = len(value.items)
@@ -183,7 +254,9 @@ class PortSpec:
if artifact.schema != self.schema.ref: if artifact.schema != self.schema.ref:
raise ValueError(f"{field} contains an artifact with the wrong schema") raise ValueError(f"{field} contains an artifact with the wrong schema")
if artifact.media_type != self.schema.media_type: if artifact.media_type != self.schema.media_type:
raise ValueError(f"{field} contains an artifact with the wrong media type") raise ValueError(
f"{field} contains an artifact with the wrong media type"
)
if artifact.size_bytes > self.schema.max_bytes: if artifact.size_bytes > self.schema.max_bytes:
raise ValueError(f"{field} exceeds its per-artifact byte limit") raise ValueError(f"{field} exceeds its per-artifact byte limit")
if self.schema.max_records is not None: if self.schema.max_records is not None:
@@ -193,10 +266,14 @@ class PortSpec:
raise ValueError(f"{field} exceeds its record limit") raise ValueError(f"{field} exceeds its record limit")
if self.schema.max_dimensions: if self.schema.max_dimensions:
if not artifact.dimensions: if not artifact.dimensions:
raise ValueError(f"{field} is missing its required dimension summary") raise ValueError(
f"{field} is missing its required dimension summary"
)
if len(artifact.dimensions) != len(self.schema.max_dimensions) or any( if len(artifact.dimensions) != len(self.schema.max_dimensions) or any(
actual > maximum actual > maximum
for actual, maximum in zip(artifact.dimensions, self.schema.max_dimensions) for actual, maximum in zip(
artifact.dimensions, self.schema.max_dimensions
)
): ):
raise ValueError(f"{field} exceeds its dimension limits") raise ValueError(f"{field} exceeds its dimension limits")
@@ -211,7 +288,9 @@ class PortSpec:
def from_dict(cls, value: object) -> "PortSpec": def from_dict(cls, value: object) -> "PortSpec":
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise ValueError("port specification must be an object") raise ValueError("port specification must be an object")
require_exact_keys(value, {"schema", "cardinality", "collection"}, "port specification") require_exact_keys(
value, {"schema", "cardinality", "collection"}, "port specification"
)
return cls( return cls(
schema=ArtifactSchema.from_dict(value["schema"]), schema=ArtifactSchema.from_dict(value["schema"]),
cardinality=value["cardinality"], # type: ignore[arg-type] cardinality=value["cardinality"], # type: ignore[arg-type]
@@ -232,16 +311,28 @@ class ArtifactRef:
dimensions: tuple[int, ...] = () dimensions: tuple[int, ...] = ()
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__(self, "artifact_id", require_uuid(self.artifact_id, "artifact_id")) object.__setattr__(
self, "artifact_id", require_uuid(self.artifact_id, "artifact_id")
)
object.__setattr__(self, "sha256", require_sha256(self.sha256, "sha256")) object.__setattr__(self, "sha256", require_sha256(self.sha256, "sha256"))
if not isinstance(self.schema, SchemaRef): if not isinstance(self.schema, SchemaRef):
raise ValueError("artifact schema must be a SchemaRef") raise ValueError("artifact schema must be a SchemaRef")
object.__setattr__(self, "media_type", require_string(self.media_type, "media_type", max_length=128)) object.__setattr__(
if "/" not in self.media_type or any(character.isspace() for character in self.media_type): self,
"media_type",
require_string(self.media_type, "media_type", max_length=128),
)
if "/" not in self.media_type or any(
character.isspace() for character in self.media_type
):
raise ValueError("media_type must be a valid type/subtype token") raise ValueError("media_type must be a valid type/subtype token")
object.__setattr__(self, "size_bytes", require_nonnegative_int(self.size_bytes, "size_bytes")) object.__setattr__(
self, "size_bytes", require_nonnegative_int(self.size_bytes, "size_bytes")
)
if self.records is not None: if self.records is not None:
object.__setattr__(self, "records", require_nonnegative_int(self.records, "records")) object.__setattr__(
self, "records", require_nonnegative_int(self.records, "records")
)
dimensions = tuple(self.dimensions) dimensions = tuple(self.dimensions)
if any( if any(
isinstance(value, bool) or not isinstance(value, int) or value < 0 isinstance(value, bool) or not isinstance(value, int) or value < 0
@@ -269,7 +360,15 @@ class ArtifactRef:
raise ValueError("artifact reference must be an object") raise ValueError("artifact reference must be an object")
require_exact_keys( require_exact_keys(
value, value,
{"artifact_id", "sha256", "schema", "media_type", "size_bytes", "records", "dimensions"}, {
"artifact_id",
"sha256",
"schema",
"media_type",
"size_bytes",
"records",
"dimensions",
},
"artifact reference", "artifact reference",
) )
dimensions = value["dimensions"] dimensions = value["dimensions"]
@@ -288,6 +387,12 @@ class ArtifactRef:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ArtifactItem: class ArtifactItem:
"""One artifact inside a collection, optionally bound to a string key.
Keys are canonical identifiers used by keyed collections (for example
shard indexes); ordered and set collections never use keys.
"""
artifact: ArtifactRef artifact: ArtifactRef
key: str | None = None key: str | None = None
@@ -295,7 +400,9 @@ class ArtifactItem:
if not isinstance(self.artifact, ArtifactRef): if not isinstance(self.artifact, ArtifactRef):
raise ValueError("artifact item must contain an ArtifactRef") raise ValueError("artifact item must contain an ArtifactRef")
if self.key is not None: if self.key is not None:
object.__setattr__(self, "key", require_identifier(self.key, "artifact key")) object.__setattr__(
self, "key", require_identifier(self.key, "artifact key")
)
def to_dict(self) -> dict[str, object]: def to_dict(self) -> dict[str, object]:
return {"key": self.key, "artifact": self.artifact.to_dict()} return {"key": self.key, "artifact": self.artifact.to_dict()}
@@ -310,17 +417,29 @@ class ArtifactItem:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ArtifactCollection: class ArtifactCollection:
"""An immutable set of artifacts with a canonical, content-addressed order.
Keyed collections sort by key; set collections sort by schema, checksum,
and size and reject duplicates; single collections hold at most one
unkeyed artifact. ``digest`` is the content pin used in provenance and
verification bindings.
"""
kind: CollectionKind kind: CollectionKind
items: tuple[ArtifactItem, ...] items: tuple[ArtifactItem, ...]
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__(self, "kind", enum_value(CollectionKind, self.kind, "collection.kind")) object.__setattr__(
self, "kind", enum_value(CollectionKind, self.kind, "collection.kind")
)
items = tuple(self.items) items = tuple(self.items)
if any(not isinstance(item, ArtifactItem) for item in items): if any(not isinstance(item, ArtifactItem) for item in items):
raise ValueError("collection items must be ArtifactItem values") raise ValueError("collection items must be ArtifactItem values")
if self.kind is CollectionKind.SINGLE: if self.kind is CollectionKind.SINGLE:
if len(items) > 1 or any(item.key is not None for item in items): if len(items) > 1 or any(item.key is not None for item in items):
raise ValueError("single collection contains at most one unkeyed artifact") raise ValueError(
"single collection contains at most one unkeyed artifact"
)
elif self.kind is CollectionKind.KEYED: elif self.kind is CollectionKind.KEYED:
if any(item.key is None for item in items): if any(item.key is None for item in items):
raise ValueError("keyed collection requires a key for every artifact") raise ValueError("keyed collection requires a key for every artifact")
@@ -333,11 +452,17 @@ class ArtifactCollection:
raise ValueError("ordered and set collections must not use keys") raise ValueError("ordered and set collections must not use keys")
if self.kind is CollectionKind.SET: if self.kind is CollectionKind.SET:
identities = [ identities = [
(item.artifact.schema, item.artifact.sha256, item.artifact.size_bytes) (
item.artifact.schema,
item.artifact.sha256,
item.artifact.size_bytes,
)
for item in items for item in items
] ]
if len(identities) != len(set(identities)): if len(identities) != len(set(identities)):
raise ValueError("set collection must not contain duplicate artifacts") raise ValueError(
"set collection must not contain duplicate artifacts"
)
items = tuple( items = tuple(
sorted( sorted(
items, items,
@@ -352,7 +477,9 @@ class ArtifactCollection:
@classmethod @classmethod
def single(cls, artifact: ArtifactRef | None) -> "ArtifactCollection": def single(cls, artifact: ArtifactRef | None) -> "ArtifactCollection":
return cls(CollectionKind.SINGLE, () if artifact is None else (ArtifactItem(artifact),)) return cls(
CollectionKind.SINGLE, () if artifact is None else (ArtifactItem(artifact),)
)
@property @property
def size_bytes(self) -> int: def size_bytes(self) -> int:
@@ -376,7 +503,10 @@ class ArtifactCollection:
return hashlib.sha256(canonical_json(payload).encode("utf-8")).hexdigest() return hashlib.sha256(canonical_json(payload).encode("utf-8")).hexdigest()
def to_dict(self) -> dict[str, object]: def to_dict(self) -> dict[str, object]:
return {"kind": self.kind.value, "items": [item.to_dict() for item in self.items]} return {
"kind": self.kind.value,
"items": [item.to_dict() for item in self.items],
}
@classmethod @classmethod
def from_dict(cls, value: object) -> "ArtifactCollection": def from_dict(cls, value: object) -> "ArtifactCollection":
@@ -405,6 +535,13 @@ def _timestamp(value: object, field: str) -> str:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class Provenance: class Provenance:
"""Immutable audit record stamped onto every sealed output.
Captures the workload pin, negotiated features, input and parameter
digests, the execution contract digest, coordinator job/task identity,
and timestamps. Verification bindings are derived from it.
"""
workload: WorkloadId workload: WorkloadId
sdk_api_version: str sdk_api_version: str
protocol_version: str protocol_version: str
@@ -433,34 +570,64 @@ class Provenance:
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not isinstance(self.workload, WorkloadId): if not isinstance(self.workload, WorkloadId):
raise ValueError("provenance workload must be a WorkloadId") raise ValueError("provenance workload must be a WorkloadId")
object.__setattr__(self, "sdk_api_version", require_string(self.sdk_api_version, "sdk_api_version")) object.__setattr__(
object.__setattr__(self, "protocol_version", require_string(self.protocol_version, "protocol_version")) self,
"sdk_api_version",
require_string(self.sdk_api_version, "sdk_api_version"),
)
object.__setattr__(
self,
"protocol_version",
require_string(self.protocol_version, "protocol_version"),
)
parse_release(self.sdk_api_version, "sdk_api_version") parse_release(self.sdk_api_version, "sdk_api_version")
parse_release(self.protocol_version, "protocol_version") parse_release(self.protocol_version, "protocol_version")
object.__setattr__( object.__setattr__(
self, self,
"manifest_schema_version", "manifest_schema_version",
require_positive_int(self.manifest_schema_version, "manifest_schema_version"), require_positive_int(
self.manifest_schema_version, "manifest_schema_version"
),
) )
object.__setattr__( object.__setattr__(
self, self,
"workflow_schema_version", "workflow_schema_version",
require_positive_int(self.workflow_schema_version, "workflow_schema_version"), require_positive_int(
self.workflow_schema_version, "workflow_schema_version"
),
) )
if not isinstance(self.verifier, ComponentRef): if not isinstance(self.verifier, ComponentRef):
raise ValueError("provenance verifier must be a ComponentRef") raise ValueError("provenance verifier must be a ComponentRef")
schemas = tuple(self.artifact_schemas) schemas = tuple(self.artifact_schemas)
if not schemas or any(not isinstance(schema, SchemaRef) for schema in schemas): if not schemas or any(not isinstance(schema, SchemaRef) for schema in schemas):
raise ValueError("provenance artifact_schemas must contain SchemaRef values") raise ValueError(
"provenance artifact_schemas must contain SchemaRef values"
)
if len(schemas) != len(set(schemas)): if len(schemas) != len(set(schemas)):
raise ValueError("provenance artifact_schemas must be unique") raise ValueError("provenance artifact_schemas must be unique")
if schemas != tuple(sorted(schemas, key=lambda schema: schema.canonical)): if schemas != tuple(sorted(schemas, key=lambda schema: schema.canonical)):
raise ValueError("provenance artifact_schemas must be in canonical order") raise ValueError("provenance artifact_schemas must be in canonical order")
object.__setattr__(self, "artifact_schemas", schemas) object.__setattr__(self, "artifact_schemas", schemas)
object.__setattr__(self, "package_digest", require_sha256(self.package_digest, "package_digest", prefixed=True)) object.__setattr__(
object.__setattr__(self, "manifest_digest", require_sha256(self.manifest_digest, "manifest_digest")) self,
object.__setattr__(self, "environment_digest", require_sha256(self.environment_digest, "environment_digest", prefixed=True)) "package_digest",
runtime = freeze_json_mapping(self.worker_runtime, "worker_runtime", forbid_locations=True) require_sha256(self.package_digest, "package_digest", prefixed=True),
)
object.__setattr__(
self,
"manifest_digest",
require_sha256(self.manifest_digest, "manifest_digest"),
)
object.__setattr__(
self,
"environment_digest",
require_sha256(
self.environment_digest, "environment_digest", prefixed=True
),
)
runtime = freeze_json_mapping(
self.worker_runtime, "worker_runtime", forbid_locations=True
)
if len(canonical_json(runtime).encode("utf-8")) > 65_536: if len(canonical_json(runtime).encode("utf-8")) > 65_536:
raise ValueError("worker_runtime exceeds 64 KiB") raise ValueError("worker_runtime exceeds 64 KiB")
object.__setattr__(self, "worker_runtime", runtime) object.__setattr__(self, "worker_runtime", runtime)
@@ -471,8 +638,16 @@ class Provenance:
if not resource_ids or len(resource_ids) != len(set(resource_ids)): if not resource_ids or len(resource_ids) != len(set(resource_ids)):
raise ValueError("allocated_resource_ids must be non-empty and unique") raise ValueError("allocated_resource_ids must be non-empty and unique")
object.__setattr__(self, "allocated_resource_ids", resource_ids) object.__setattr__(self, "allocated_resource_ids", resource_ids)
object.__setattr__(self, "parameters_digest", require_sha256(self.parameters_digest, "parameters_digest")) object.__setattr__(
object.__setattr__(self, "input_collection_digest", require_sha256(self.input_collection_digest, "input_collection_digest")) self,
"parameters_digest",
require_sha256(self.parameters_digest, "parameters_digest"),
)
object.__setattr__(
self,
"input_collection_digest",
require_sha256(self.input_collection_digest, "input_collection_digest"),
)
object.__setattr__( object.__setattr__(
self, self,
"execution_contract_digest", "execution_contract_digest",
@@ -488,7 +663,9 @@ class Provenance:
) )
for name, version in selected_features.items(): for name, version in selected_features.items():
require_identifier(name, "provenance selected feature") require_identifier(name, "provenance selected feature")
require_string(version, "provenance selected feature version", max_length=32) require_string(
version, "provenance selected feature version", max_length=32
)
parse_release(version, "provenance selected feature version") parse_release(version, "provenance selected feature version")
for name, fallback in optional_fallbacks.items(): for name, fallback in optional_fallbacks.items():
require_identifier(name, "provenance fallback feature") require_identifier(name, "provenance fallback feature")
@@ -497,21 +674,34 @@ class Provenance:
raise ValueError("provenance feature cannot be selected and fallbacked") raise ValueError("provenance feature cannot be selected and fallbacked")
object.__setattr__(self, "selected_features", selected_features) object.__setattr__(self, "selected_features", selected_features)
object.__setattr__(self, "optional_fallbacks", optional_fallbacks) object.__setattr__(self, "optional_fallbacks", optional_fallbacks)
object.__setattr__(self, "job_id", require_uuid(self.job_id, "provenance.job_id")) object.__setattr__(
object.__setattr__(self, "task_id", require_uuid(self.task_id, "provenance.task_id")) self, "job_id", require_uuid(self.job_id, "provenance.job_id")
object.__setattr__(self, "started_at", _timestamp(self.started_at, "started_at")) )
object.__setattr__(self, "finished_at", _timestamp(self.finished_at, "finished_at")) object.__setattr__(
if datetime.fromisoformat(self.finished_at.replace("Z", "+00:00")) < datetime.fromisoformat( self, "task_id", require_uuid(self.task_id, "provenance.task_id")
self.started_at.replace("Z", "+00:00") )
): object.__setattr__(
self, "started_at", _timestamp(self.started_at, "started_at")
)
object.__setattr__(
self, "finished_at", _timestamp(self.finished_at, "finished_at")
)
if datetime.fromisoformat(
self.finished_at.replace("Z", "+00:00")
) < datetime.fromisoformat(self.started_at.replace("Z", "+00:00")):
raise ValueError("finished_at must not precede started_at") raise ValueError("finished_at must not precede started_at")
trust_mode = require_identifier(self.trust_mode, "provenance.trust_mode") trust_mode = require_identifier(self.trust_mode, "provenance.trust_mode")
if trust_mode not in {"trusted", "verified", "untrusted_quorum"}: if trust_mode not in {"trusted", "verified", "untrusted_quorum"}:
raise ValueError("provenance.trust_mode is unsupported") raise ValueError("provenance.trust_mode is unsupported")
object.__setattr__(self, "trust_mode", trust_mode) object.__setattr__(self, "trust_mode", trust_mode)
if self.random_seed is not None and (isinstance(self.random_seed, bool) or not isinstance(self.random_seed, int)): if self.random_seed is not None and (
isinstance(self.random_seed, bool) or not isinstance(self.random_seed, int)
):
raise ValueError("random_seed must be an integer") raise ValueError("random_seed must be an integer")
lineage = tuple(require_uuid(value, "checkpoint_lineage") for value in self.checkpoint_lineage) lineage = tuple(
require_uuid(value, "checkpoint_lineage")
for value in self.checkpoint_lineage
)
if len(lineage) != len(set(lineage)): if len(lineage) != len(set(lineage)):
raise ValueError("checkpoint_lineage must not contain duplicate artifacts") raise ValueError("checkpoint_lineage must not contain duplicate artifacts")
object.__setattr__(self, "checkpoint_lineage", lineage) object.__setattr__(self, "checkpoint_lineage", lineage)
@@ -549,21 +739,43 @@ class Provenance:
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise ValueError("provenance must be an object") raise ValueError("provenance must be an object")
fields = { fields = {
"workload", "sdk_api_version", "protocol_version", "manifest_schema_version", "workload",
"workflow_schema_version", "verifier", "artifact_schemas", "package_digest", "sdk_api_version",
"manifest_digest", "environment_digest", "worker_runtime", "allocated_resource_ids", "protocol_version",
"parameters_digest", "input_collection_digest", "execution_contract_digest", "manifest_schema_version",
"selected_features", "optional_fallbacks", "workflow_schema_version",
"job_id", "task_id", "verifier",
"started_at", "finished_at", "artifact_schemas",
"trust_mode", "random_seed", "checkpoint_lineage", "package_digest",
"manifest_digest",
"environment_digest",
"worker_runtime",
"allocated_resource_ids",
"parameters_digest",
"input_collection_digest",
"execution_contract_digest",
"selected_features",
"optional_fallbacks",
"job_id",
"task_id",
"started_at",
"finished_at",
"trust_mode",
"random_seed",
"checkpoint_lineage",
} }
require_exact_keys(value, fields, "provenance") require_exact_keys(value, fields, "provenance")
resource_ids = value["allocated_resource_ids"] resource_ids = value["allocated_resource_ids"]
artifact_schemas = value["artifact_schemas"] artifact_schemas = value["artifact_schemas"]
lineage = value["checkpoint_lineage"] lineage = value["checkpoint_lineage"]
if not isinstance(resource_ids, list) or not isinstance(artifact_schemas, list) or not isinstance(lineage, list): if (
raise ValueError("provenance resource IDs and checkpoint lineage must be arrays") not isinstance(resource_ids, list)
or not isinstance(artifact_schemas, list)
or not isinstance(lineage, list)
):
raise ValueError(
"provenance resource IDs and checkpoint lineage must be arrays"
)
return cls( return cls(
workload=WorkloadId.from_dict(value["workload"]), workload=WorkloadId.from_dict(value["workload"]),
sdk_api_version=value["sdk_api_version"], # type: ignore[arg-type] sdk_api_version=value["sdk_api_version"], # type: ignore[arg-type]
@@ -571,7 +783,9 @@ class Provenance:
manifest_schema_version=value["manifest_schema_version"], # type: ignore[arg-type] manifest_schema_version=value["manifest_schema_version"], # type: ignore[arg-type]
workflow_schema_version=value["workflow_schema_version"], # type: ignore[arg-type] workflow_schema_version=value["workflow_schema_version"], # type: ignore[arg-type]
verifier=ComponentRef.from_dict(value["verifier"]), verifier=ComponentRef.from_dict(value["verifier"]),
artifact_schemas=tuple(SchemaRef.from_dict(item) for item in artifact_schemas), artifact_schemas=tuple(
SchemaRef.from_dict(item) for item in artifact_schemas
),
package_digest=value["package_digest"], # type: ignore[arg-type] package_digest=value["package_digest"], # type: ignore[arg-type]
manifest_digest=value["manifest_digest"], # type: ignore[arg-type] manifest_digest=value["manifest_digest"], # type: ignore[arg-type]
environment_digest=value["environment_digest"], # type: ignore[arg-type] environment_digest=value["environment_digest"], # type: ignore[arg-type]
@@ -601,7 +815,9 @@ class OutputManifest:
schema_version: int = OUTPUT_SCHEMA_VERSION schema_version: int = OUTPUT_SCHEMA_VERSION
def __post_init__(self) -> None: def __post_init__(self) -> None:
require_schema_version(self.schema_version, OUTPUT_SCHEMA_VERSION, "output schema_version") require_schema_version(
self.schema_version, OUTPUT_SCHEMA_VERSION, "output schema_version"
)
object.__setattr__(self, "task_key", require_task_key(self.task_key)) object.__setattr__(self, "task_key", require_task_key(self.task_key))
if not isinstance(self.outputs, Mapping) or not self.outputs: if not isinstance(self.outputs, Mapping) or not self.outputs:
raise ValueError("outputs must be a non-empty object") raise ValueError("outputs must be a non-empty object")
@@ -638,7 +854,9 @@ class OutputManifest:
details.append("missing " + ", ".join(missing)) details.append("missing " + ", ".join(missing))
if unexpected: if unexpected:
details.append("unexpected " + ", ".join(unexpected)) details.append("unexpected " + ", ".join(unexpected))
raise ValueError("output ports do not match the declaration: " + "; ".join(details)) raise ValueError(
"output ports do not match the declaration: " + "; ".join(details)
)
total = 0 total = 0
for name, port in expected.items(): for name, port in expected.items():
if not isinstance(port, PortSpec): if not isinstance(port, PortSpec):
@@ -691,7 +909,10 @@ class OutputManifest:
return cls( return cls(
schema_version=value["schema_version"], # type: ignore[arg-type] schema_version=value["schema_version"], # type: ignore[arg-type]
task_key=value["task_key"], # type: ignore[arg-type] task_key=value["task_key"], # type: ignore[arg-type]
outputs={name: ArtifactCollection.from_dict(item) for name, item in outputs.items()}, outputs={
name: ArtifactCollection.from_dict(item)
for name, item in outputs.items()
},
metrics=value["metrics"], # type: ignore[arg-type] metrics=value["metrics"], # type: ignore[arg-type]
provenance=Provenance.from_dict(value["provenance"]), provenance=Provenance.from_dict(value["provenance"]),
) )
+10 -3
View File
@@ -342,7 +342,11 @@ class MapReduceWorkload:
""" """
import csv import csv
if isinstance(self.shard_rows, bool) or not isinstance(self.shard_rows, int) or self.shard_rows < 1: if (
isinstance(self.shard_rows, bool)
or not isinstance(self.shard_rows, int)
or self.shard_rows < 1
):
raise ValueError("shard_rows must be a positive integer") raise ValueError("shard_rows must be a positive integer")
media_type = self.input_port.schema.media_type media_type = self.input_port.schema.media_type
if media_type == "text/tab-separated-values": if media_type == "text/tab-separated-values":
@@ -564,6 +568,7 @@ class MapReduceWorkload:
def run(self, context: TaskContext) -> OutputManifest: def run(self, context: TaskContext) -> OutputManifest:
context.cancellation.raise_if_cancelled() context.cancellation.raise_if_cancelled()
assert self.map_stage_inputs is not None
workspace = context.workspace workspace = context.workspace
workspace.mkdir(parents=True, exist_ok=True) workspace.mkdir(parents=True, exist_ok=True)
inputs: dict[str, Path] = {} inputs: dict[str, Path] = {}
@@ -572,8 +577,10 @@ class MapReduceWorkload:
if collection is None: if collection is None:
raise ValueError(f"map task requires the {name} input") raise ValueError(f"map task requires the {name} input")
port.validate_collection(collection, f"map input {name}") port.validate_collection(collection, f"map input {name}")
assert collection.items item = next(iter(collection.items), None)
inputs[name] = context.catalog.materialize(collection.items[0].artifact) if item is None:
raise ValueError(f"map task input {name} is empty")
inputs[name] = context.catalog.materialize(item.artifact)
output_path = workspace / "result.csv" output_path = workspace / "result.csv"
metrics = self.compute_shard( metrics = self.compute_shard(
inputs, inputs,
+210 -79
View File
@@ -66,7 +66,8 @@ class LocalArtifactStore:
tuple[int | None, tuple[int, ...]], tuple[int | None, tuple[int, ...]],
], ],
], ],
] | None = None, ]
| None = None,
) -> None: ) -> None:
self.root = root.resolve() self.root = root.resolve()
self.root.mkdir(parents=True, exist_ok=True) self.root.mkdir(parents=True, exist_ok=True)
@@ -96,7 +97,9 @@ class LocalArtifactStore:
try: try:
source_fd = os.open(path, source_flags) source_fd = os.open(path, source_flags)
except OSError as error: except OSError as error:
raise ValueError("artifact sink could not open a regular non-symlink file") from error raise ValueError(
"artifact sink could not open a regular non-symlink file"
) from error
try: try:
return self.seal_descriptor( return self.seal_descriptor(
source_fd, source_fd,
@@ -116,7 +119,11 @@ class LocalArtifactStore:
dimensions: tuple[int, ...] = (), dimensions: tuple[int, ...] = (),
) -> ArtifactRef: ) -> ArtifactRef:
"""Copy and validate one already safely opened regular-file descriptor.""" """Copy and validate one already safely opened regular-file descriptor."""
if isinstance(descriptor, bool) or not isinstance(descriptor, int) or descriptor < 0: if (
isinstance(descriptor, bool)
or not isinstance(descriptor, int)
or descriptor < 0
):
raise ValueError("artifact descriptor must be an open file descriptor") raise ValueError("artifact descriptor must be an open file descriptor")
if not isinstance(declaration, ArtifactSchema): if not isinstance(declaration, ArtifactSchema):
raise ValueError("artifact declaration must be an ArtifactSchema") raise ValueError("artifact declaration must be an ArtifactSchema")
@@ -127,9 +134,10 @@ class LocalArtifactStore:
try: try:
if not stat.S_ISREG(os.fstat(source_fd).st_mode): if not stat.S_ISREG(os.fstat(source_fd).st_mode):
raise ValueError("artifact sink accepts only regular files") raise ValueError("artifact sink accepts only regular files")
with os.fdopen(source_fd, "rb", closefd=True) as source_file, os.fdopen( with (
temporary_fd, "wb", closefd=True os.fdopen(source_fd, "rb", closefd=True) as source_file,
) as destination_file: os.fdopen(temporary_fd, "wb", closefd=True) as destination_file,
):
source_fd = -1 source_fd = -1
temporary_fd = -1 temporary_fd = -1
for block in iter(lambda: source_file.read(1024 * 1024), b""): for block in iter(lambda: source_file.read(1024 * 1024), b""):
@@ -150,14 +158,22 @@ class LocalArtifactStore:
declaration, declaration,
) )
if records is not None and records != measured_records: if records is not None and records != measured_records:
raise ValueError("artifact record summary does not match inspected content") raise ValueError(
"artifact record summary does not match inspected content"
)
if dimensions and dimensions != measured_dimensions: if dimensions and dimensions != measured_dimensions:
raise ValueError("artifact dimension summary does not match inspected content") raise ValueError(
"artifact dimension summary does not match inspected content"
)
if declaration.max_records is not None: if declaration.max_records is not None:
if measured_records is None: if measured_records is None:
raise ValueError("artifact validator did not produce a required record count") raise ValueError(
"artifact validator did not produce a required record count"
)
if measured_records > declaration.max_records: if measured_records > declaration.max_records:
raise ValueError("sealed artifact exceeds its declared record limit") raise ValueError(
"sealed artifact exceeds its declared record limit"
)
if declaration.max_dimensions: if declaration.max_dimensions:
if len(measured_dimensions) != len(declaration.max_dimensions) or any( if len(measured_dimensions) != len(declaration.max_dimensions) or any(
actual > maximum actual > maximum
@@ -166,7 +182,9 @@ class LocalArtifactStore:
declaration.max_dimensions, declaration.max_dimensions,
) )
): ):
raise ValueError("sealed artifact exceeds its declared dimension limits") raise ValueError(
"sealed artifact exceeds its declared dimension limits"
)
reference = ArtifactRef( reference = ArtifactRef(
artifact_id, artifact_id,
digest, digest,
@@ -178,7 +196,9 @@ class LocalArtifactStore:
) )
with self._lock: with self._lock:
if destination.is_symlink(): if destination.is_symlink():
raise ValueError("local artifact destination must not be a symbolic link") raise ValueError(
"local artifact destination must not be a symbolic link"
)
if destination.exists(): if destination.exists():
if not destination.is_file() or _sha256_file(destination) != digest: if not destination.is_file() or _sha256_file(destination) != digest:
raise ValueError("local artifact identity collision") raise ValueError("local artifact identity collision")
@@ -188,7 +208,9 @@ class LocalArtifactStore:
destination.chmod(0o444) destination.chmod(0o444)
existing = self._references.get(artifact_id) existing = self._references.get(artifact_id)
if existing is not None and existing != reference: if existing is not None and existing != reference:
raise ValueError("local artifact identity was reused with different metadata") raise ValueError(
"local artifact identity was reused with different metadata"
)
self._paths[artifact_id] = destination self._paths[artifact_id] = destination
self._references[artifact_id] = reference self._references[artifact_id] = reference
self._refcounts[artifact_id] = self._refcounts.get(artifact_id, 0) + 1 self._refcounts[artifact_id] = self._refcounts.get(artifact_id, 0) + 1
@@ -254,7 +276,9 @@ class LocalArtifactStore:
configuration = dict(declaration.validator_configuration) configuration = dict(declaration.validator_configuration)
unknown = set(configuration) - {"columns", "required_columns"} unknown = set(configuration) - {"columns", "required_columns"}
if unknown: if unknown:
raise ValueError("delimited-table validator configuration has unknown fields") raise ValueError(
"delimited-table validator configuration has unknown fields"
)
columns = configuration.get("columns") columns = configuration.get("columns")
required = configuration.get("required_columns", ()) required = configuration.get("required_columns", ())
if columns is not None and not isinstance(columns, (list, tuple)): if columns is not None and not isinstance(columns, (list, tuple)):
@@ -267,34 +291,54 @@ class LocalArtifactStore:
(expected_columns or (), "columns"), (expected_columns or (), "columns"),
(required_columns, "required_columns"), (required_columns, "required_columns"),
): ):
if ( if any(not isinstance(value, str) or not value for value in values) or len(
any(not isinstance(value, str) or not value for value in values) values
or len(values) != len(set(values)) ) != len(set(values)):
):
raise ValueError(f"delimited-table {field_name} must be unique strings") raise ValueError(f"delimited-table {field_name} must be unique strings")
delimiter = "\t" if declaration.media_type == "text/tab-separated-values" else "," delimiter = (
"\t" if declaration.media_type == "text/tab-separated-values" else ","
)
try: try:
with path.open("r", encoding="utf-8", newline="") as source_file: with path.open("r", encoding="utf-8", newline="") as source_file:
reader = csv.reader(source_file, delimiter=delimiter) reader = csv.reader(source_file, delimiter=delimiter)
try: try:
header = tuple(next(reader)) header = tuple(next(reader))
except StopIteration as error: except StopIteration as error:
raise ValueError("delimited-table artifact must contain a header") from error raise ValueError(
if not header or any(not value for value in header) or len(header) != len(set(header)): "delimited-table artifact must contain a header"
) from error
if (
not header
or any(not value for value in header)
or len(header) != len(set(header))
):
raise ValueError("delimited-table artifact has an invalid header") raise ValueError("delimited-table artifact has an invalid header")
if expected_columns is not None and header != expected_columns: if expected_columns is not None and header != expected_columns:
raise ValueError("delimited-table artifact header does not match its schema") raise ValueError(
"delimited-table artifact header does not match its schema"
)
if not set(required_columns).issubset(header): if not set(required_columns).issubset(header):
raise ValueError("delimited-table artifact is missing required columns") raise ValueError(
"delimited-table artifact is missing required columns"
)
count = 0 count = 0
for row in reader: for row in reader:
if len(row) != len(header): if len(row) != len(header):
raise ValueError("delimited-table artifact has an inconsistent row width") raise ValueError(
"delimited-table artifact has an inconsistent row width"
)
count += 1 count += 1
if declaration.max_records is not None and count > declaration.max_records: if (
raise ValueError("sealed artifact exceeds its declared record limit") declaration.max_records is not None
and count > declaration.max_records
):
raise ValueError(
"sealed artifact exceeds its declared record limit"
)
except (UnicodeError, csv.Error) as error: except (UnicodeError, csv.Error) as error:
raise ValueError("sealed tabular artifact is not valid bounded text") from error raise ValueError(
"sealed tabular artifact is not valid bounded text"
) from error
return count, () return count, ()
@staticmethod @staticmethod
@@ -325,8 +369,15 @@ class LocalArtifactStore:
ValueError("non-finite JSON number") ValueError("non-finite JSON number")
), ),
) )
except (UnicodeError, json.JSONDecodeError, ValueError, RecursionError) as error: except (
raise ValueError("sealed JSON artifact is not a valid bounded document") from error UnicodeError,
json.JSONDecodeError,
ValueError,
RecursionError,
) as error:
raise ValueError(
"sealed JSON artifact is not a valid bounded document"
) from error
if top_level == "array" and not isinstance(value, list): if top_level == "array" and not isinstance(value, list):
raise ValueError("JSON artifact must contain a top-level array") raise ValueError("JSON artifact must contain a top-level array")
if top_level == "object" and not isinstance(value, dict): if top_level == "object" and not isinstance(value, dict):
@@ -392,9 +443,13 @@ class LocalArtifactStore:
path = self._paths[artifact.artifact_id] path = self._paths[artifact.artifact_id]
stored = self._references[artifact.artifact_id] stored = self._references[artifact.artifact_id]
except KeyError as error: except KeyError as error:
raise ValueError("artifact is not present in the local store") from error raise ValueError(
"artifact is not present in the local store"
) from error
if stored != artifact: if stored != artifact:
raise ValueError("artifact metadata does not match the sealed local reference") raise ValueError(
"artifact metadata does not match the sealed local reference"
)
if ( if (
path.is_symlink() path.is_symlink()
or not path.is_file() or not path.is_file()
@@ -479,13 +534,13 @@ class ScopedArtifactSink:
try: try:
lexical_relative = lexical.relative_to(self._workspace) lexical_relative = lexical.relative_to(self._workspace)
except ValueError as error: except ValueError as error:
raise ValueError("attempt artifact must remain inside its workspace") from error raise ValueError(
"attempt artifact must remain inside its workspace"
) from error
if not lexical_relative.parts: if not lexical_relative.parts:
raise ValueError("attempt artifact must name a file inside its workspace") raise ValueError("attempt artifact must name a file inside its workspace")
directory_flags = ( directory_flags = (
os.O_RDONLY os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
| getattr(os, "O_DIRECTORY", 0)
| getattr(os, "O_NOFOLLOW", 0)
) )
opened_directories: list[int] = [] opened_directories: list[int] = []
file_descriptor = -1 file_descriptor = -1
@@ -530,11 +585,15 @@ class ScopedArtifactSink:
existing = self._sealed.get(reference.artifact_id) existing = self._sealed.get(reference.artifact_id)
if existing is not None and existing != reference: if existing is not None and existing != reference:
self._store.release(reference) self._store.release(reference)
raise ValueError("attempt sealed conflicting metadata for one artifact") raise ValueError(
"attempt sealed conflicting metadata for one artifact"
)
if existing is None: if existing is None:
if self._sealed_bytes + reference.size_bytes > self._max_bytes: if self._sealed_bytes + reference.size_bytes > self._max_bytes:
self._store.release(reference) self._store.release(reference)
raise ValueError("attempt artifact bytes exceed their sink limit") raise ValueError(
"attempt artifact bytes exceed their sink limit"
)
self._sealed[reference.artifact_id] = reference self._sealed[reference.artifact_id] = reference
self._sealed_bytes += reference.size_bytes self._sealed_bytes += reference.size_bytes
if self._transaction is not None: if self._transaction is not None:
@@ -567,7 +626,9 @@ class ScopedArtifactCatalog:
raise ValueError("catalog allowlist must contain ArtifactRef values") raise ValueError("catalog allowlist must contain ArtifactRef values")
existing = allowed.get(artifact.artifact_id) existing = allowed.get(artifact.artifact_id)
if existing is not None and existing != artifact: if existing is not None and existing != artifact:
raise ValueError("catalog allowlist contains conflicting artifact metadata") raise ValueError(
"catalog allowlist contains conflicting artifact metadata"
)
allowed[artifact.artifact_id] = artifact allowed[artifact.artifact_id] = artifact
self.__allowed = allowed self.__allowed = allowed
resolved_workspace = workspace.resolve() resolved_workspace = workspace.resolve()
@@ -620,6 +681,8 @@ class ScopedArtifactCatalog:
class CancellationFlag: class CancellationFlag:
"""A thread-safe cancellation signal for local conformance runs."""
def __init__(self) -> None: def __init__(self) -> None:
self._event = Event() self._event = Event()
@@ -636,6 +699,12 @@ class CancellationFlag:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class LocalPlanningContext: class LocalPlanningContext:
"""Scoped catalog/sink/workspace handed to a planner by the local executor.
Wraps a ``LocalArtifactStore`` into attempt-scoped catalog and sink so a
planner can materialize job inputs and seal shards.
"""
catalog: ArtifactCatalog catalog: ArtifactCatalog
sink: ArtifactSink sink: ArtifactSink
workspace: Path workspace: Path
@@ -651,8 +720,14 @@ class LocalPlanningContext:
if any(not isinstance(value, ArtifactRef) for value in allowed_artifacts): if any(not isinstance(value, ArtifactRef) for value in allowed_artifacts):
raise ValueError("allowed_artifacts must contain ArtifactRef values") raise ValueError("allowed_artifacts must contain ArtifactRef values")
object.__setattr__(self, "allowed_artifacts", allowed_artifacts) object.__setattr__(self, "allowed_artifacts", allowed_artifacts)
object.__setattr__(self, "max_artifacts", require_positive_int(self.max_artifacts, "max_artifacts")) object.__setattr__(
object.__setattr__(self, "max_bytes", require_positive_int(self.max_bytes, "max_bytes")) self,
"max_artifacts",
require_positive_int(self.max_artifacts, "max_artifacts"),
)
object.__setattr__(
self, "max_bytes", require_positive_int(self.max_bytes, "max_bytes")
)
if isinstance(self.catalog, LocalArtifactStore): if isinstance(self.catalog, LocalArtifactStore):
object.__setattr__( object.__setattr__(
self, self,
@@ -675,6 +750,12 @@ class LocalPlanningContext:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class LocalTaskContext: class LocalTaskContext:
"""Scoped context handed to a map/reduce handler by the local executor.
Carries the digest-pinned ``TaskSpec``, attempt workspace, cancellation
flag, provenance, and the accepted inputs for reducers.
"""
task: TaskSpec task: TaskSpec
catalog: ArtifactCatalog catalog: ArtifactCatalog
sink: ArtifactSink sink: ArtifactSink
@@ -689,8 +770,14 @@ class LocalTaskContext:
def __post_init__(self) -> None: def __post_init__(self) -> None:
workspace = self.workspace.resolve() workspace = self.workspace.resolve()
object.__setattr__(self, "workspace", workspace) object.__setattr__(self, "workspace", workspace)
object.__setattr__(self, "max_artifacts", require_positive_int(self.max_artifacts, "max_artifacts")) object.__setattr__(
object.__setattr__(self, "max_bytes", require_positive_int(self.max_bytes, "max_bytes")) self,
"max_artifacts",
require_positive_int(self.max_artifacts, "max_artifacts"),
)
object.__setattr__(
self, "max_bytes", require_positive_int(self.max_bytes, "max_bytes")
)
if isinstance(self.catalog, LocalArtifactStore): if isinstance(self.catalog, LocalArtifactStore):
allowed_artifacts = tuple( allowed_artifacts = tuple(
item.artifact item.artifact
@@ -741,7 +828,9 @@ def _provenance(
job_id: str, job_id: str,
task_id: str, task_id: str,
) -> Provenance: ) -> Provenance:
parameters_digest = hashlib.sha256(canonical_json(task.parameters).encode("utf-8")).hexdigest() parameters_digest = hashlib.sha256(
canonical_json(task.parameters).encode("utf-8")
).hexdigest()
return Provenance( return Provenance(
workload=definition.manifest.workload, workload=definition.manifest.workload,
sdk_api_version=task.sdk_api_version, sdk_api_version=task.sdk_api_version,
@@ -755,9 +844,7 @@ def _provenance(
item.artifact.schema item.artifact.schema
for collection in task.inputs.values() for collection in task.inputs.values()
for item in collection.items for item in collection.items
}.union( }.union(port.schema.ref for port in task.expected_outputs.values()),
port.schema.ref for port in task.expected_outputs.values()
),
key=lambda value: value.canonical, key=lambda value: value.canonical,
) )
), ),
@@ -768,7 +855,9 @@ def _provenance(
allocated_resource_ids=(allocation.allocation_id,) + allocation.accelerator_ids, allocated_resource_ids=(allocation.allocation_id,) + allocation.accelerator_ids,
parameters_digest=parameters_digest, parameters_digest=parameters_digest,
input_collection_digest=_input_digest(task.inputs), input_collection_digest=_input_digest(task.inputs),
execution_contract_digest=hashlib.sha256(task.to_json().encode("utf-8")).hexdigest(), execution_contract_digest=hashlib.sha256(
task.to_json().encode("utf-8")
).hexdigest(),
selected_features=task.selected_features, selected_features=task.selected_features,
optional_fallbacks=task.optional_fallbacks, optional_fallbacks=task.optional_fallbacks,
job_id=job_id, job_id=job_id,
@@ -800,7 +889,7 @@ def _verification_binding(manifest: OutputManifest) -> VerificationBinding:
manifest_schema_version=provenance.manifest_schema_version, manifest_schema_version=provenance.manifest_schema_version,
workflow_schema_version=provenance.workflow_schema_version, workflow_schema_version=provenance.workflow_schema_version,
artifact_schemas=provenance.artifact_schemas, artifact_schemas=provenance.artifact_schemas,
trust_mode=provenance.trust_mode, trust_mode=TrustMode(provenance.trust_mode),
) )
@@ -828,15 +917,23 @@ class LocalCoreBatchExecutor:
self.resources = ResourcePool(runtime.inventory, max_concurrency=1) self.resources = ResourcePool(runtime.inventory, max_concurrency=1)
@staticmethod @staticmethod
def _assert_supported_profile(request: JobRequest, definition: WorkloadDefinition) -> None: def _assert_supported_profile(
request: JobRequest, definition: WorkloadDefinition
) -> None:
if request.trust_mode is not TrustMode.TRUSTED: if request.trust_mode is not TrustMode.TRUSTED:
raise ValueError("local conformance execution supports only trusted workloads") raise ValueError(
"local conformance execution supports only trusted workloads"
)
workflow = definition.manifest.workflow workflow = definition.manifest.workflow
if workflow.failure_policy is not WorkflowFailurePolicy.FAIL_FAST: if workflow.failure_policy is not WorkflowFailurePolicy.FAIL_FAST:
raise ValueError("local conformance execution supports only fail-fast workflows") raise ValueError(
"local conformance execution supports only fail-fast workflows"
)
for stage in workflow.stages: for stage in workflow.stages:
if stage.kind not in {StageKind.MAP, StageKind.REDUCE}: if stage.kind not in {StageKind.MAP, StageKind.REDUCE}:
raise ValueError("local conformance execution does not implement advanced stages") raise ValueError(
"local conformance execution does not implement advanced stages"
)
execution = stage.execution execution = stage.execution
if ( if (
execution.process_model is not ProcessModel.SINGLE execution.process_model is not ProcessModel.SINGLE
@@ -845,7 +942,9 @@ class LocalCoreBatchExecutor:
or execution.native_threads != 1 or execution.native_threads != 1
or execution.nested_parallelism or execution.nested_parallelism
): ):
raise ValueError("local conformance execution supports one non-nested host thread") raise ValueError(
"local conformance execution supports one non-nested host thread"
)
if execution.network is not NetworkPolicy.TRUSTED: if execution.network is not NetworkPolicy.TRUSTED:
raise ValueError( raise ValueError(
"local conformance execution cannot enforce a restricted network policy" "local conformance execution cannot enforce a restricted network policy"
@@ -857,19 +956,22 @@ class LocalCoreBatchExecutor:
or stage.gang is not None or stage.gang is not None
or stage.resources.accelerator_count or stage.resources.accelerator_count
): ):
raise ValueError("local conformance execution cannot enforce this stage profile") raise ValueError(
"local conformance execution cannot enforce this stage profile"
)
if stage.retry.max_attempts != 1: if stage.retry.max_attempts != 1:
raise ValueError("local conformance execution does not implement retries") raise ValueError(
reducers = tuple(stage for stage in workflow.stages if stage.kind is StageKind.REDUCE) "local conformance execution does not implement retries"
)
reducers = tuple(
stage for stage in workflow.stages if stage.kind is StageKind.REDUCE
)
if len(reducers) == 1: if len(reducers) == 1:
reducer = reducers[0] reducer = reducers[0]
if ( if set(workflow.outputs) != set(reducer.outputs) or any(
set(workflow.outputs) != set(reducer.outputs) external_name != reference.port
or any( or reference.stage_id != reducer.stage_id
external_name != reference.port for external_name, reference in workflow.outputs.items()
or reference.stage_id != reducer.stage_id
for external_name, reference in workflow.outputs.items()
)
): ):
raise ValueError( raise ValueError(
"local conformance execution requires identity-mapped reducer outputs" "local conformance execution requires identity-mapped reducer outputs"
@@ -930,11 +1032,14 @@ class LocalCoreBatchExecutor:
or task.protocol_version != self.runtime.protocol_version or task.protocol_version != self.runtime.protocol_version
or task.manifest_schema_version or task.manifest_schema_version
!= definition.manifest.manifest_schema_version != definition.manifest.manifest_schema_version
or task.workflow_schema_version != definition.manifest.workflow.schema_version or task.workflow_schema_version
!= definition.manifest.workflow.schema_version
or task.environment_digest != definition.manifest.environment.digest or task.environment_digest != definition.manifest.environment.digest
or task.verifier != stage.verifier or task.verifier != stage.verifier
): ):
raise ValueError("task resolved pins do not match the selected runtime and manifest") raise ValueError(
"task resolved pins do not match the selected runtime and manifest"
)
provenance = _provenance( provenance = _provenance(
definition, definition,
self.runtime, self.runtime,
@@ -960,9 +1065,13 @@ class LocalCoreBatchExecutor:
if not isinstance(manifest, OutputManifest): if not isinstance(manifest, OutputManifest):
raise ValueError("workload handler must return an OutputManifest") raise ValueError("workload handler must return an OutputManifest")
if manifest.task_key != task.task_key: if manifest.task_key != task.task_key:
raise ValueError("handler output task_key does not match its trusted task") raise ValueError(
"handler output task_key does not match its trusted task"
)
if manifest.provenance != provenance: if manifest.provenance != provenance:
raise ValueError("handler output provenance does not match its trusted context") raise ValueError(
"handler output provenance does not match its trusted context"
)
manifest.validate_against( manifest.validate_against(
task.expected_outputs, task.expected_outputs,
max_output_bytes=max_output_bytes, max_output_bytes=max_output_bytes,
@@ -1012,14 +1121,16 @@ class LocalCoreBatchExecutor:
expected_outputs, # type: ignore[arg-type] expected_outputs, # type: ignore[arg-type]
max_output_bytes, max_output_bytes,
binding=_verification_binding(output), binding=_verification_binding(output),
trust_mode=output.provenance.trust_mode, trust_mode=TrustMode(output.provenance.trust_mode),
), ),
CandidateOutputs((output,)), CandidateOutputs((output,)),
) )
if not isinstance(decision, VerificationDecision): if not isinstance(decision, VerificationDecision):
raise ValueError("declared verifier must return a VerificationDecision") raise ValueError("declared verifier must return a VerificationDecision")
if decision.verifier != verifier_ref: if decision.verifier != verifier_ref:
raise ValueError("verification decision identity does not match the declared verifier") raise ValueError(
"verification decision identity does not match the declared verifier"
)
if decision.status is not VerificationStatus.ACCEPTED: if decision.status is not VerificationStatus.ACCEPTED:
raise ValueError("task output did not pass its declared verifier") raise ValueError("task output did not pass its declared verifier")
@@ -1059,13 +1170,18 @@ class LocalCoreBatchExecutor:
self._assert_supported_profile(request, definition) self._assert_supported_profile(request, definition)
workflow = definition.manifest.workflow workflow = definition.manifest.workflow
map_stages = [stage for stage in workflow.stages if stage.kind is StageKind.MAP] map_stages = [stage for stage in workflow.stages if stage.kind is StageKind.MAP]
reduce_stages = [stage for stage in workflow.stages if stage.kind is StageKind.REDUCE] reduce_stages = [
stage for stage in workflow.stages if stage.kind is StageKind.REDUCE
]
unsupported = [ unsupported = [
stage for stage in workflow.stages stage
for stage in workflow.stages
if stage.kind not in {StageKind.MAP, StageKind.REDUCE} if stage.kind not in {StageKind.MAP, StageKind.REDUCE}
] ]
if len(map_stages) != 1 or len(reduce_stages) != 1 or unsupported: if len(map_stages) != 1 or len(reduce_stages) != 1 or unsupported:
raise ValueError("local core-batch executor supports one static map stage and one reducer") raise ValueError(
"local core-batch executor supports one static map stage and one reducer"
)
limits = definition.manifest.limits limits = definition.manifest.limits
output_limit = min(limits.max_output_bytes, workflow.max_output_bytes) output_limit = min(limits.max_output_bytes, workflow.max_output_bytes)
job_id = str(uuid4()) job_id = str(uuid4())
@@ -1103,7 +1219,9 @@ class LocalCoreBatchExecutor:
} }
for issued in planning.sink.sealed_references: for issued in planning.sink.sealed_references:
if planned_references.get(issued.artifact_id) != issued: if planned_references.get(issued.artifact_id) != issued:
raise ValueError("planner sealed an artifact that is not referenced by its plan") raise ValueError(
"planner sealed an artifact that is not referenced by its plan"
)
authorized_plan_inputs = { authorized_plan_inputs = {
item.artifact.artifact_id: item.artifact item.artifact.artifact_id: item.artifact
for collection in request.inputs.values() for collection in request.inputs.values()
@@ -1141,7 +1259,9 @@ class LocalCoreBatchExecutor:
for reference in workflow.outputs.values() for reference in workflow.outputs.values()
) )
): ):
raise ValueError("local core-batch executor requires a canonical map-to-reduce DAG") raise ValueError(
"local core-batch executor requires a canonical map-to-reduce DAG"
)
runner = definition.runners[map_stage.entry_point] runner = definition.runners[map_stage.entry_point]
map_results: list[OutputManifest] = [] map_results: list[OutputManifest] = []
for task_index, task in enumerate(plan.tasks): for task_index, task in enumerate(plan.tasks):
@@ -1171,16 +1291,22 @@ class LocalCoreBatchExecutor:
raise ValueError("job exceeds the cumulative output byte limit") raise ValueError("job exceeds the cumulative output byte limit")
map_results.append(manifest) map_results.append(manifest)
if len(map_results) != len(plan.tasks): if len(map_results) != len(plan.tasks):
raise ValueError("map execution did not produce exactly one accepted result per task") raise ValueError(
"map execution did not produce exactly one accepted result per task"
)
if len(map_stage.outputs) != 1 or len(reducer_stage.inputs) != 1: if len(map_stage.outputs) != 1 or len(reducer_stage.inputs) != 1:
raise ValueError("core map/reduce adapter requires one map output and one reducer input") raise ValueError(
"core map/reduce adapter requires one map output and one reducer input"
)
map_port = next(iter(map_stage.outputs)) map_port = next(iter(map_stage.outputs))
reducer_input_name = next(iter(reducer_stage.inputs)) reducer_input_name = next(iter(reducer_stage.inputs))
partial_items: list[ArtifactItem] = [] partial_items: list[ArtifactItem] = []
for task, result in zip(plan.tasks, map_results): for task, result in zip(plan.tasks, map_results):
collection = result.outputs[map_port] collection = result.outputs[map_port]
if len(collection.items) != 1: if len(collection.items) != 1:
raise ValueError("core map stage must produce exactly one partial per planned task") raise ValueError(
"core map stage must produce exactly one partial per planned task"
)
partial_items.append( partial_items.append(
ArtifactItem( ArtifactItem(
collection.items[0].artifact, collection.items[0].artifact,
@@ -1188,8 +1314,11 @@ class LocalCoreBatchExecutor:
) )
) )
if len(partial_items) != len(plan.tasks): if len(partial_items) != len(plan.tasks):
raise ValueError("core map stage must produce exactly one partial per planned task") raise ValueError(
"core map stage must produce exactly one partial per planned task"
)
accepted = ArtifactCollection(CollectionKind.KEYED, tuple(partial_items)) accepted = ArtifactCollection(CollectionKind.KEYED, tuple(partial_items))
assert reducer_stage.verifier is not None
reducer_task = TaskSpec( reducer_task = TaskSpec(
workload=plan.workload, workload=plan.workload,
package_digest=plan.package_digest, package_digest=plan.package_digest,
@@ -1234,5 +1363,7 @@ class LocalCoreBatchExecutor:
) )
if output_bytes > output_limit: if output_bytes > output_limit:
raise ValueError("job exceeds the cumulative output byte limit") raise ValueError("job exceeds the cumulative output byte limit")
final.validate_against(definition.manifest.outputs, max_output_bytes=output_limit) final.validate_against(
definition.manifest.outputs, max_output_bytes=output_limit
)
return final return final
+155 -31
View File
@@ -23,6 +23,12 @@ from .resources import ResourceAllocation, ResourceRequirements
class ProcessModel(str, Enum): class ProcessModel(str, Enum):
"""How a task's code is executed.
``SINGLE`` is the only profile the local conformance runtime executes;
the other models require runtime features that are fail-closed in v1.
"""
SINGLE = "single" SINGLE = "single"
PROCESS_POOL = "process_pool" PROCESS_POOL = "process_pool"
THREAD_POOL = "thread_pool" THREAD_POOL = "thread_pool"
@@ -30,6 +36,12 @@ class ProcessModel(str, Enum):
class NetworkPolicy(str, Enum): class NetworkPolicy(str, Enum):
"""Network isolation declared for a stage.
``TRUSTED`` is the only policy the local conformance runtime accepts;
stricter policies require runtime enforcement.
"""
NONE = "none" NONE = "none"
COORDINATOR_ARTIFACTS_ONLY = "coordinator_artifacts_only" COORDINATOR_ARTIFACTS_ONLY = "coordinator_artifacts_only"
ALLOWLISTED_EGRESS = "allowlisted_egress" ALLOWLISTED_EGRESS = "allowlisted_egress"
@@ -37,6 +49,8 @@ class NetworkPolicy(str, Enum):
class FailureCategory(str, Enum): class FailureCategory(str, Enum):
"""Sanitized failure classification for retry and reporting policy."""
INPUT = "input" INPUT = "input"
SCIENTIFIC = "scientific" SCIENTIFIC = "scientific"
RESOURCE = "resource" RESOURCE = "resource"
@@ -48,13 +62,23 @@ class FailureCategory(str, Enum):
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class RetryPolicy: class RetryPolicy:
"""Retry budget and backoff for a stage.
``max_attempts`` greater than one requires the runtime to advertise
``retries``; the local conformance executor rejects retries.
"""
max_attempts: int = 1 max_attempts: int = 1
retryable_categories: tuple[FailureCategory, ...] = () retryable_categories: tuple[FailureCategory, ...] = ()
initial_backoff_seconds: int = 1 initial_backoff_seconds: int = 1
max_backoff_seconds: int = 60 max_backoff_seconds: int = 60
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__(self, "max_attempts", require_positive_int(self.max_attempts, "retry.max_attempts")) object.__setattr__(
self,
"max_attempts",
require_positive_int(self.max_attempts, "retry.max_attempts"),
)
categories = tuple( categories = tuple(
enum_value(FailureCategory, value, "retryable_category") enum_value(FailureCategory, value, "retryable_category")
for value in self.retryable_categories for value in self.retryable_categories
@@ -65,22 +89,30 @@ class RetryPolicy:
object.__setattr__( object.__setattr__(
self, self,
"initial_backoff_seconds", "initial_backoff_seconds",
require_nonnegative_int(self.initial_backoff_seconds, "retry.initial_backoff_seconds"), require_nonnegative_int(
self.initial_backoff_seconds, "retry.initial_backoff_seconds"
),
) )
object.__setattr__( object.__setattr__(
self, self,
"max_backoff_seconds", "max_backoff_seconds",
require_nonnegative_int(self.max_backoff_seconds, "retry.max_backoff_seconds"), require_nonnegative_int(
self.max_backoff_seconds, "retry.max_backoff_seconds"
),
) )
if self.max_backoff_seconds < self.initial_backoff_seconds: if self.max_backoff_seconds < self.initial_backoff_seconds:
raise ValueError("retry max_backoff_seconds must not be less than initial_backoff_seconds") raise ValueError(
"retry max_backoff_seconds must not be less than initial_backoff_seconds"
)
if self.max_attempts == 1 and categories: if self.max_attempts == 1 and categories:
raise ValueError("a non-retrying policy must not list retryable categories") raise ValueError("a non-retrying policy must not list retryable categories")
def to_dict(self) -> dict[str, object]: def to_dict(self) -> dict[str, object]:
return { return {
"max_attempts": self.max_attempts, "max_attempts": self.max_attempts,
"retryable_categories": [category.value for category in self.retryable_categories], "retryable_categories": [
category.value for category in self.retryable_categories
],
"initial_backoff_seconds": self.initial_backoff_seconds, "initial_backoff_seconds": self.initial_backoff_seconds,
"max_backoff_seconds": self.max_backoff_seconds, "max_backoff_seconds": self.max_backoff_seconds,
} }
@@ -89,7 +121,12 @@ class RetryPolicy:
def from_dict(cls, value: object) -> "RetryPolicy": def from_dict(cls, value: object) -> "RetryPolicy":
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise ValueError("retry policy must be an object") raise ValueError("retry policy must be an object")
fields = {"max_attempts", "retryable_categories", "initial_backoff_seconds", "max_backoff_seconds"} fields = {
"max_attempts",
"retryable_categories",
"initial_backoff_seconds",
"max_backoff_seconds",
}
require_exact_keys(value, fields, "retry policy") require_exact_keys(value, fields, "retry policy")
categories = value["retryable_categories"] categories = value["retryable_categories"]
if not isinstance(categories, list): if not isinstance(categories, list):
@@ -104,6 +141,12 @@ class RetryPolicy:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class CheckpointPolicy: class CheckpointPolicy:
"""Checkpoint declaration for a stage.
Declared but not executable until a runtime advertises ``checkpoints``;
enabled policies require a schema and a compatibility version.
"""
enabled: bool = False enabled: bool = False
schema: SchemaRef | None = None schema: SchemaRef | None = None
compatibility_version: int | None = None compatibility_version: int | None = None
@@ -113,23 +156,38 @@ class CheckpointPolicy:
if not isinstance(self.enabled, bool): if not isinstance(self.enabled, bool):
raise ValueError("checkpoint.enabled must be a boolean") raise ValueError("checkpoint.enabled must be a boolean")
if not self.enabled: if not self.enabled:
if any(value is not None for value in (self.schema, self.compatibility_version, self.interval_seconds)): if any(
raise ValueError("disabled checkpoint policy must not declare checkpoint fields") value is not None
for value in (
self.schema,
self.compatibility_version,
self.interval_seconds,
)
):
raise ValueError(
"disabled checkpoint policy must not declare checkpoint fields"
)
return return
if not isinstance(self.schema, SchemaRef): if not isinstance(self.schema, SchemaRef):
raise ValueError("enabled checkpoint policy requires a schema") raise ValueError("enabled checkpoint policy requires a schema")
if self.compatibility_version is None: if self.compatibility_version is None:
raise ValueError("enabled checkpoint policy requires a compatibility_version") raise ValueError(
"enabled checkpoint policy requires a compatibility_version"
)
object.__setattr__( object.__setattr__(
self, self,
"compatibility_version", "compatibility_version",
require_positive_int(self.compatibility_version, "checkpoint.compatibility_version"), require_positive_int(
self.compatibility_version, "checkpoint.compatibility_version"
),
) )
if self.interval_seconds is not None: if self.interval_seconds is not None:
object.__setattr__( object.__setattr__(
self, self,
"interval_seconds", "interval_seconds",
require_positive_int(self.interval_seconds, "checkpoint.interval_seconds"), require_positive_int(
self.interval_seconds, "checkpoint.interval_seconds"
),
) )
def to_dict(self) -> dict[str, object]: def to_dict(self) -> dict[str, object]:
@@ -171,27 +229,52 @@ class ExecutionProfile:
secret_handles: tuple[str, ...] = () secret_handles: tuple[str, ...] = ()
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__(self, "profile", require_identifier(self.profile, "execution.profile")) object.__setattr__(
object.__setattr__(self, "process_model", enum_value(ProcessModel, self.process_model, "process_model")) self, "profile", require_identifier(self.profile, "execution.profile")
object.__setattr__(self, "max_processes", require_positive_int(self.max_processes, "max_processes")) )
object.__setattr__(
self,
"process_model",
enum_value(ProcessModel, self.process_model, "process_model"),
)
object.__setattr__(
self,
"max_processes",
require_positive_int(self.max_processes, "max_processes"),
)
object.__setattr__( object.__setattr__(
self, self,
"threads_per_process", "threads_per_process",
require_positive_int(self.threads_per_process, "threads_per_process"), require_positive_int(self.threads_per_process, "threads_per_process"),
) )
object.__setattr__(self, "native_threads", require_positive_int(self.native_threads, "native_threads")) object.__setattr__(
self,
"native_threads",
require_positive_int(self.native_threads, "native_threads"),
)
if not isinstance(self.nested_parallelism, bool): if not isinstance(self.nested_parallelism, bool):
raise ValueError("nested_parallelism must be a boolean") raise ValueError("nested_parallelism must be a boolean")
object.__setattr__(self, "network", enum_value(NetworkPolicy, self.network, "network")) object.__setattr__(
object.__setattr__(self, "timeout_seconds", require_positive_int(self.timeout_seconds, "timeout_seconds")) self, "network", enum_value(NetworkPolicy, self.network, "network")
)
object.__setattr__(
self,
"timeout_seconds",
require_positive_int(self.timeout_seconds, "timeout_seconds"),
)
object.__setattr__( object.__setattr__(
self, self,
"cancellation_grace_seconds", "cancellation_grace_seconds",
require_nonnegative_int(self.cancellation_grace_seconds, "cancellation_grace_seconds"), require_nonnegative_int(
self.cancellation_grace_seconds, "cancellation_grace_seconds"
),
) )
if not isinstance(self.checkpoint, CheckpointPolicy): if not isinstance(self.checkpoint, CheckpointPolicy):
raise ValueError("checkpoint must be a CheckpointPolicy") raise ValueError("checkpoint must be a CheckpointPolicy")
egress = tuple(require_string(value, "allowed_egress", max_length=253) for value in self.allowed_egress) egress = tuple(
require_string(value, "allowed_egress", max_length=253)
for value in self.allowed_egress
)
if len(egress) != len(set(egress)): if len(egress) != len(set(egress)):
raise ValueError("allowed_egress must be unique") raise ValueError("allowed_egress must be unique")
if self.network is NetworkPolicy.ALLOWLISTED_EGRESS and not egress: if self.network is NetworkPolicy.ALLOWLISTED_EGRESS and not egress:
@@ -199,7 +282,9 @@ class ExecutionProfile:
if self.network is not NetworkPolicy.ALLOWLISTED_EGRESS and egress: if self.network is not NetworkPolicy.ALLOWLISTED_EGRESS and egress:
raise ValueError("allowed_egress is valid only for allowlisted egress") raise ValueError("allowed_egress is valid only for allowlisted egress")
object.__setattr__(self, "allowed_egress", egress) object.__setattr__(self, "allowed_egress", egress)
handles = tuple(require_identifier(value, "secret_handle") for value in self.secret_handles) handles = tuple(
require_identifier(value, "secret_handle") for value in self.secret_handles
)
if len(handles) != len(set(handles)): if len(handles) != len(set(handles)):
raise ValueError("secret_handles must be unique") raise ValueError("secret_handles must be unique")
if handles and self.network is NetworkPolicy.NONE: if handles and self.network is NetworkPolicy.NONE:
@@ -208,8 +293,14 @@ class ExecutionProfile:
if self.process_model is ProcessModel.SINGLE and ( if self.process_model is ProcessModel.SINGLE and (
self.max_processes != 1 or self.threads_per_process != 1 self.max_processes != 1 or self.threads_per_process != 1
): ):
raise ValueError("single process model requires one process and one Python thread") raise ValueError(
if not self.nested_parallelism and self.threads_per_process > 1 and self.native_threads > 1: "single process model requires one process and one Python thread"
)
if (
not self.nested_parallelism
and self.threads_per_process > 1
and self.native_threads > 1
):
raise ValueError("nested thread pools require nested_parallelism=true") raise ValueError("nested thread pools require nested_parallelism=true")
@property @property
@@ -222,7 +313,9 @@ class ExecutionProfile:
if self.timeout_seconds > resources.max_duration_seconds: if self.timeout_seconds > resources.max_duration_seconds:
raise ValueError("execution timeout exceeds the resource maximum duration") raise ValueError("execution timeout exceeds the resource maximum duration")
def allocation_environment(self, allocation: ResourceAllocation) -> Mapping[str, str]: def allocation_environment(
self, allocation: ResourceAllocation
) -> Mapping[str, str]:
"""Return only allocation-derived thread/device isolation variables.""" """Return only allocation-derived thread/device isolation variables."""
if not isinstance(allocation, ResourceAllocation): if not isinstance(allocation, ResourceAllocation):
raise ValueError("allocation must be a ResourceAllocation") raise ValueError("allocation must be a ResourceAllocation")
@@ -261,15 +354,26 @@ class ExecutionProfile:
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise ValueError("execution profile must be an object") raise ValueError("execution profile must be an object")
fields = { fields = {
"profile", "process_model", "max_processes", "threads_per_process", "profile",
"native_threads", "nested_parallelism", "network", "timeout_seconds", "process_model",
"cancellation_grace_seconds", "checkpoint", "allowed_egress", "secret_handles", "max_processes",
"threads_per_process",
"native_threads",
"nested_parallelism",
"network",
"timeout_seconds",
"cancellation_grace_seconds",
"checkpoint",
"allowed_egress",
"secret_handles",
} }
require_exact_keys(value, fields, "execution profile") require_exact_keys(value, fields, "execution profile")
allowed_egress = value["allowed_egress"] allowed_egress = value["allowed_egress"]
secret_handles = value["secret_handles"] secret_handles = value["secret_handles"]
if not isinstance(allowed_egress, list) or not isinstance(secret_handles, list): if not isinstance(allowed_egress, list) or not isinstance(secret_handles, list):
raise ValueError("execution allowed_egress and secret_handles must be arrays") raise ValueError(
"execution allowed_egress and secret_handles must be arrays"
)
return cls( return cls(
profile=value["profile"], # type: ignore[arg-type] profile=value["profile"], # type: ignore[arg-type]
process_model=value["process_model"], # type: ignore[arg-type] process_model=value["process_model"], # type: ignore[arg-type]
@@ -288,6 +392,12 @@ class ExecutionProfile:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class FailureReport: class FailureReport:
"""A sanitized, location-free failure report for durable audit trails.
Message and evidence are bounded and reject local paths and transport
URLs; evidence is limited to 16 KiB.
"""
code: str code: str
category: FailureCategory category: FailureCategory
retryable: bool retryable: bool
@@ -296,13 +406,27 @@ class FailureReport:
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__(self, "code", require_identifier(self.code, "failure.code")) object.__setattr__(self, "code", require_identifier(self.code, "failure.code"))
object.__setattr__(self, "category", enum_value(FailureCategory, self.category, "failure.category")) object.__setattr__(
self,
"category",
enum_value(FailureCategory, self.category, "failure.category"),
)
if not isinstance(self.retryable, bool): if not isinstance(self.retryable, bool):
raise ValueError("failure.retryable must be a boolean") raise ValueError("failure.retryable must be a boolean")
object.__setattr__(self, "message", require_safe_message(self.message, "failure.message", max_length=512)) object.__setattr__(
evidence = freeze_json_mapping(self.evidence, "failure.evidence", forbid_locations=True) self,
"message",
require_safe_message(self.message, "failure.message", max_length=512),
)
evidence = freeze_json_mapping(
self.evidence, "failure.evidence", forbid_locations=True
)
import json import json
if len(json.dumps(thaw_json(evidence), allow_nan=False).encode("utf-8")) > 16_384:
if (
len(json.dumps(thaw_json(evidence), allow_nan=False).encode("utf-8"))
> 16_384
):
raise ValueError("failure evidence exceeds 16 KiB") raise ValueError("failure evidence exceeds 16 KiB")
object.__setattr__(self, "evidence", evidence) object.__setattr__(self, "evidence", evidence)
+47 -8
View File
@@ -1,4 +1,10 @@
"""Versioned identities used across the SciMesh workload SDK.""" """Versioned identities used across the SciMesh workload SDK.
The module-level constants ``SDK_API_VERSION``, ``MANIFEST_SCHEMA_VERSION``,
``WORKFLOW_SCHEMA_VERSION``, ``TASK_SCHEMA_VERSION``, and
``OUTPUT_SCHEMA_VERSION`` pin the current wire/schema versions; manifests
declare explicit compatibility ranges against them.
"""
from __future__ import annotations from __future__ import annotations
@@ -35,7 +41,9 @@ class VersionRange:
expression: str expression: str
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__(self, "expression", validate_version_range(self.expression, "version range")) object.__setattr__(
self, "expression", validate_version_range(self.expression, "version range")
)
def contains(self, version: str) -> bool: def contains(self, version: str) -> bool:
return version_in_range(version, self.expression) return version_in_range(version, self.expression)
@@ -50,12 +58,16 @@ class VersionRange:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class WorkloadId: class WorkloadId:
"""The exact identity of a workload: canonical hyphenated name and semantic version."""
name: str name: str
version: str version: str
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__(self, "name", require_workload_name(self.name)) object.__setattr__(self, "name", require_workload_name(self.name))
object.__setattr__(self, "version", require_semver(self.version, "workload.version")) object.__setattr__(
self, "version", require_semver(self.version, "workload.version")
)
def to_dict(self) -> dict[str, str]: def to_dict(self) -> dict[str, str]:
return {"name": self.name, "version": self.version} return {"name": self.name, "version": self.version}
@@ -70,12 +82,22 @@ class WorkloadId:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class SchemaRef: class SchemaRef:
"""A versioned artifact schema identity (``name@version``).
Schemas are content contracts: two artifacts share a schema only when
their ``SchemaRef`` values are equal.
"""
name: str name: str
version: int version: int
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__(self, "name", require_identifier(self.name, "schema.name")) object.__setattr__(self, "name", require_identifier(self.name, "schema.name"))
if isinstance(self.version, bool) or not isinstance(self.version, int) or self.version < 1: if (
isinstance(self.version, bool)
or not isinstance(self.version, int)
or self.version < 1
):
raise ValueError("schema.version must be a positive integer") raise ValueError("schema.version must be a positive integer")
@property @property
@@ -111,8 +133,14 @@ class ComponentRef:
version: int version: int
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__(self, "name", require_identifier(self.name, "component.name")) object.__setattr__(
if isinstance(self.version, bool) or not isinstance(self.version, int) or self.version < 1: self, "name", require_identifier(self.name, "component.name")
)
if (
isinstance(self.version, bool)
or not isinstance(self.version, int)
or self.version < 1
):
raise ValueError("component.version must be a positive integer") raise ValueError("component.version must be a positive integer")
@property @property
@@ -135,6 +163,12 @@ class ComponentRef:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class FeatureRequirement: class FeatureRequirement:
"""A versioned feature a workload requires or optionally selects.
Optional features may declare a fallback that negotiation records when
the runtime does not provide the feature.
"""
name: str name: str
versions: VersionRange versions: VersionRange
fallback: str | None = None fallback: str | None = None
@@ -144,10 +178,15 @@ class FeatureRequirement:
if not isinstance(self.versions, VersionRange): if not isinstance(self.versions, VersionRange):
raise ValueError("feature.versions must be a VersionRange") raise ValueError("feature.versions must be a VersionRange")
if self.fallback is not None: if self.fallback is not None:
object.__setattr__(self, "fallback", require_identifier(self.fallback, "feature.fallback")) object.__setattr__(
self, "fallback", require_identifier(self.fallback, "feature.fallback")
)
def to_dict(self) -> dict[str, object]: def to_dict(self) -> dict[str, object]:
result: dict[str, object] = {"name": self.name, "versions": self.versions.expression} result: dict[str, object] = {
"name": self.name,
"versions": self.versions.expression,
}
if self.fallback is not None: if self.fallback is not None:
result["fallback"] = self.fallback result["fallback"] = self.fallback
return result return result
+44 -16
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import hashlib import hashlib
import importlib.util import importlib.util
import os
from importlib import metadata from importlib import metadata
from pathlib import Path from pathlib import Path
@@ -22,11 +23,17 @@ def installed_distribution_digest(
files are excluded because they are neither stable wheel payloads nor used files are excluded because they are neither stable wheel payloads nor used
by the registry's cache-isolated discovery import. by the registry's cache-isolated discovery import.
""" """
installed = metadata.distribution(distribution) if isinstance(distribution, str) else distribution installed = (
metadata.distribution(distribution)
if isinstance(distribution, str)
else distribution
)
raw_top_level = installed.read_text("top_level.txt") raw_top_level = installed.read_text("top_level.txt")
if raw_top_level is None: if raw_top_level is None:
raise ValueError("installed distribution does not declare top-level packages") raise ValueError("installed distribution does not declare top-level packages")
declared_top_levels = [line.strip() for line in raw_top_level.splitlines() if line.strip()] declared_top_levels = [
line.strip() for line in raw_top_level.splitlines() if line.strip()
]
if any(not value.isidentifier() for value in declared_top_levels): if any(not value.isidentifier() for value in declared_top_levels):
raise ValueError("installed distribution declares an invalid top-level package") raise ValueError("installed distribution declares an invalid top-level package")
top_levels = set(declared_top_levels) top_levels = set(declared_top_levels)
@@ -34,55 +41,74 @@ def installed_distribution_digest(
raise ValueError("installed distribution has no measurable top-level package") raise ValueError("installed distribution has no measurable top-level package")
declared_files = tuple(installed.files or ()) declared_files = tuple(installed.files or ())
editable_bootstrap = any( editable_bootstrap = any(
Path(str(item)).name.startswith("__editable__") and Path(str(item)).suffix == ".pth" Path(str(item)).name.startswith("__editable__")
and Path(str(item)).suffix == ".pth"
for item in declared_files for item in declared_files
) )
if editable_bootstrap and not allow_editable: if editable_bootstrap and not allow_editable:
raise ValueError("editable workload installations are not accepted for secure discovery") raise ValueError(
"editable workload installations are not accepted for secure discovery"
)
for item in declared_files: for item in declared_files:
relative = Path(str(item)) relative = Path(str(item))
suffix = relative.suffix.lower() suffix = relative.suffix.lower()
if suffix == ".pth" and not allow_editable: if suffix == ".pth" and not allow_editable:
raise ValueError("installed workload distribution declares a .pth bootstrap") raise ValueError(
"installed workload distribution declares a .pth bootstrap"
)
if suffix in {".pyc", ".pyo"} and "__pycache__" not in relative.parts: if suffix in {".pyc", ".pyo"} and "__pycache__" not in relative.parts:
raise ValueError("installed workload distribution declares sourceless bytecode") raise ValueError(
"installed workload distribution declares sourceless bytecode"
)
selected: list[tuple[str, Path]] = [] selected: list[tuple[str, Path]] = []
for top_level in sorted(top_levels): for top_level in sorted(top_levels):
root = Path(installed.locate_file(top_level)) root = Path(str(installed.locate_file(top_level)))
if not root.exists(): if not root.exists():
# PEP 660 editable distributions may expose source packages through # PEP 660 editable distributions may expose source packages through
# a meta-path finder rather than a physical site-packages path. # a meta-path finder rather than a physical site-packages path.
spec = importlib.util.find_spec(top_level) spec = importlib.util.find_spec(top_level)
locations = tuple(spec.submodule_search_locations or ()) if spec is not None else () locations = (
tuple(spec.submodule_search_locations or ()) if spec is not None else ()
)
if len(locations) > 1: if len(locations) > 1:
raise ValueError("shared namespace packages are not supported for workload integrity") raise ValueError(
"shared namespace packages are not supported for workload integrity"
)
if locations: if locations:
root = Path(locations[0]) root = Path(locations[0])
if root.is_symlink(): if root.is_symlink():
raise ValueError("installed workload package root must not be a symbolic link") raise ValueError(
"installed workload package root must not be a symbolic link"
)
if root.is_dir(): if root.is_dir():
candidates = root.rglob("*") candidates = root.rglob("*")
for path in candidates: for path in candidates:
if path.is_symlink(): if path.is_symlink():
raise ValueError("installed workload package contains a symbolic-link payload") raise ValueError(
"installed workload package contains a symbolic-link payload"
)
if not path.is_file(): if not path.is_file():
continue continue
relative_parts = path.relative_to(root).parts relative_parts = path.relative_to(root).parts
if "__pycache__" in relative_parts: if "__pycache__" in relative_parts:
continue continue
if path.suffix.lower() in {".pyc", ".pyo"}: if path.suffix.lower() in {".pyc", ".pyo"}:
raise ValueError("installed workload package contains sourceless bytecode") raise ValueError(
"installed workload package contains sourceless bytecode"
)
relative = f"{top_level}/{path.relative_to(root).as_posix()}" relative = f"{top_level}/{path.relative_to(root).as_posix()}"
selected.append((relative, path)) selected.append((relative, path))
continue continue
module = Path(installed.locate_file(top_level + ".py")) module = Path(str(installed.locate_file(top_level + ".py")))
if not module.exists(): if not module.exists():
spec = importlib.util.find_spec(top_level) spec = importlib.util.find_spec(top_level)
if spec is not None and spec.origin is not None: if spec is not None and spec.origin is not None:
module = Path(spec.origin) module = Path(spec.origin)
if module.is_symlink() or not module.is_file(): if module.is_symlink() or not module.is_file():
raise ValueError("installed workload package contains a missing top-level payload") raise ValueError(
"installed workload package contains a missing top-level payload"
)
selected.append((top_level + ".py", module)) selected.append((top_level + ".py", module))
# Include declared package data outside top-level import trees. Generated # Include declared package data outside top-level import trees. Generated
# console wrappers and installer metadata are excluded; executable .pth and # console wrappers and installer metadata are excluded; executable .pth and
@@ -106,9 +132,11 @@ def installed_distribution_digest(
or "__pycache__" in relative.parts or "__pycache__" in relative.parts
): ):
continue continue
path = Path(installed.locate_file(item)) path = Path(str(installed.locate_file(item)))
if path.is_symlink(): if path.is_symlink():
raise ValueError("installed workload distribution contains a symbolic-link payload") raise ValueError(
"installed workload distribution contains a symbolic-link payload"
)
if not path.is_file() or text in selected_names: if not path.is_file() or text in selected_names:
continue continue
selected.append((text, path)) selected.append((text, path))
+203 -44
View File
@@ -34,6 +34,12 @@ from .schema import validate_schema_definition
class DeterminismProfile(str, Enum): class DeterminismProfile(str, Enum):
"""How a workload's output is guaranteed to repeat.
``BYTE_EXACT`` is the only profile eligible for ``untrusted_quorum`` in
v1; the other profiles require future verifier or trust policies.
"""
BYTE_EXACT = "byte_exact" BYTE_EXACT = "byte_exact"
CANONICAL_EXACT = "canonical_exact" CANONICAL_EXACT = "canonical_exact"
NUMERIC_TOLERANCE = "numeric_tolerance" NUMERIC_TOLERANCE = "numeric_tolerance"
@@ -43,6 +49,13 @@ class DeterminismProfile(str, Enum):
class TrustMode(str, Enum): class TrustMode(str, Enum):
"""Who may execute a workload and what acceptance requires.
``TRUSTED`` accepts a single execution; ``VERIFIED`` requires a
coordinator-owned binding; ``UNTRUSTED_QUORUM`` requires distinct owners
to produce identical whole-artifact SHA-256 digests.
"""
TRUSTED = "trusted" TRUSTED = "trusted"
VERIFIED = "verified" VERIFIED = "verified"
UNTRUSTED_QUORUM = "untrusted_quorum" UNTRUSTED_QUORUM = "untrusted_quorum"
@@ -50,27 +63,49 @@ class TrustMode(str, Enum):
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class PackageSpec: class PackageSpec:
"""Identity pin of the installed distribution providing the workload.
``digest`` is the measured content pin (``sha256:`` prefix) that
discovery compares before importing an entry point.
"""
distribution: str distribution: str
digest: str digest: str
signature: str | None = None signature: str | None = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
distribution = require_string(self.distribution, "package.distribution", max_length=128).lower() distribution = require_string(
self.distribution, "package.distribution", max_length=128
).lower()
if not re.fullmatch(r"[a-z0-9]+(?:[-_.][a-z0-9]+)*", distribution): if not re.fullmatch(r"[a-z0-9]+(?:[-_.][a-z0-9]+)*", distribution):
raise ValueError("package.distribution must be a canonical Python distribution name") raise ValueError(
"package.distribution must be a canonical Python distribution name"
)
object.__setattr__(self, "distribution", distribution.replace("_", "-")) object.__setattr__(self, "distribution", distribution.replace("_", "-"))
object.__setattr__(self, "digest", require_sha256(self.digest, "package.digest", prefixed=True)) object.__setattr__(
self, "digest", require_sha256(self.digest, "package.digest", prefixed=True)
)
if self.signature is not None: if self.signature is not None:
object.__setattr__(self, "signature", require_string(self.signature, "package.signature", max_length=512)) object.__setattr__(
self,
"signature",
require_string(self.signature, "package.signature", max_length=512),
)
def to_dict(self) -> dict[str, object]: def to_dict(self) -> dict[str, object]:
return {"distribution": self.distribution, "digest": self.digest, "signature": self.signature} return {
"distribution": self.distribution,
"digest": self.digest,
"signature": self.signature,
}
@classmethod @classmethod
def from_dict(cls, value: object) -> "PackageSpec": def from_dict(cls, value: object) -> "PackageSpec":
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise ValueError("package specification must be an object") raise ValueError("package specification must be an object")
require_exact_keys(value, {"distribution", "digest", "signature"}, "package specification") require_exact_keys(
value, {"distribution", "digest", "signature"}, "package specification"
)
return cls( return cls(
distribution=value["distribution"], # type: ignore[arg-type] distribution=value["distribution"], # type: ignore[arg-type]
digest=value["digest"], # type: ignore[arg-type] digest=value["digest"], # type: ignore[arg-type]
@@ -80,23 +115,43 @@ class PackageSpec:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class EnvironmentSpec: class EnvironmentSpec:
"""Pinned execution environment (kind, digest, metadata).
Negotiation fails unless the runtime inventory advertises this exact
environment digest.
"""
kind: str kind: str
digest: str digest: str
metadata: Mapping[str, Any] metadata: Mapping[str, Any]
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__(self, "kind", require_identifier(self.kind, "environment.kind")) object.__setattr__(
object.__setattr__(self, "digest", require_sha256(self.digest, "environment.digest", prefixed=True)) self, "kind", require_identifier(self.kind, "environment.kind")
object.__setattr__(self, "metadata", freeze_json_mapping(self.metadata, "environment.metadata")) )
object.__setattr__(
self,
"digest",
require_sha256(self.digest, "environment.digest", prefixed=True),
)
object.__setattr__(
self, "metadata", freeze_json_mapping(self.metadata, "environment.metadata")
)
def to_dict(self) -> dict[str, object]: def to_dict(self) -> dict[str, object]:
return {"kind": self.kind, "digest": self.digest, "metadata": thaw_json(self.metadata)} return {
"kind": self.kind,
"digest": self.digest,
"metadata": thaw_json(self.metadata),
}
@classmethod @classmethod
def from_dict(cls, value: object) -> "EnvironmentSpec": def from_dict(cls, value: object) -> "EnvironmentSpec":
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise ValueError("environment specification must be an object") raise ValueError("environment specification must be an object")
require_exact_keys(value, {"kind", "digest", "metadata"}, "environment specification") require_exact_keys(
value, {"kind", "digest", "metadata"}, "environment specification"
)
return cls( return cls(
kind=value["kind"], # type: ignore[arg-type] kind=value["kind"], # type: ignore[arg-type]
digest=value["digest"], # type: ignore[arg-type] digest=value["digest"], # type: ignore[arg-type]
@@ -106,6 +161,12 @@ class EnvironmentSpec:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class VerifierSpec: class VerifierSpec:
"""The manifest acceptance verifier and its bounded configuration.
The verifier must be installed in the definition and its handler
configuration must match this declaration exactly.
"""
verifier: ComponentRef verifier: ComponentRef
configuration: Mapping[str, Any] configuration: Mapping[str, Any]
@@ -128,7 +189,9 @@ class VerifierSpec:
def from_dict(cls, value: object) -> "VerifierSpec": def from_dict(cls, value: object) -> "VerifierSpec":
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise ValueError("verifier specification must be an object") raise ValueError("verifier specification must be an object")
require_exact_keys(value, {"verifier", "configuration"}, "verifier specification") require_exact_keys(
value, {"verifier", "configuration"}, "verifier specification"
)
return cls( return cls(
verifier=ComponentRef.from_dict(value["verifier"]), verifier=ComponentRef.from_dict(value["verifier"]),
configuration=value["configuration"], # type: ignore[arg-type] configuration=value["configuration"], # type: ignore[arg-type]
@@ -137,6 +200,12 @@ class VerifierSpec:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class WorkloadLimits: class WorkloadLimits:
"""Hard resource and size bounds enforced by planning and execution.
Covers input bytes, task count, output bytes, parameter bytes, and the
total artifact count for one job.
"""
max_input_bytes: int max_input_bytes: int
max_tasks: int max_tasks: int
max_output_bytes: int max_output_bytes: int
@@ -145,9 +214,17 @@ class WorkloadLimits:
def __post_init__(self) -> None: def __post_init__(self) -> None:
for field in ( for field in (
"max_input_bytes", "max_tasks", "max_output_bytes", "max_parameter_bytes", "max_artifacts" "max_input_bytes",
"max_tasks",
"max_output_bytes",
"max_parameter_bytes",
"max_artifacts",
): ):
object.__setattr__(self, field, require_positive_int(getattr(self, field), f"limits.{field}")) object.__setattr__(
self,
field,
require_positive_int(getattr(self, field), f"limits.{field}"),
)
def to_dict(self) -> dict[str, int]: def to_dict(self) -> dict[str, int]:
return { return {
@@ -163,7 +240,11 @@ class WorkloadLimits:
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise ValueError("workload limits must be an object") raise ValueError("workload limits must be an object")
fields = { fields = {
"max_input_bytes", "max_tasks", "max_output_bytes", "max_parameter_bytes", "max_artifacts", "max_input_bytes",
"max_tasks",
"max_output_bytes",
"max_parameter_bytes",
"max_artifacts",
} }
require_exact_keys(value, fields, "workload limits") require_exact_keys(value, fields, "workload limits")
return cls(**value) # type: ignore[arg-type] return cls(**value) # type: ignore[arg-type]
@@ -186,6 +267,15 @@ def _ports(
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class WorkloadManifest: class WorkloadManifest:
"""The installed workload's complete, immutable declaration.
Pins identity, SDK/protocol compatibility ranges, package and environment
digests, the strict parameter schema, the workflow DAG, external ports,
determinism, trust modes, the acceptance verifier, limits, capabilities,
and conformance profiles. ``digest`` is the canonical JSON content pin
carried by every plan and task.
"""
sdk_api: VersionRange sdk_api: VersionRange
protocol: VersionRange protocol: VersionRange
workload: WorkloadId workload: WorkloadId
@@ -212,16 +302,33 @@ class WorkloadManifest:
MANIFEST_SCHEMA_VERSION, MANIFEST_SCHEMA_VERSION,
"manifest_schema_version", "manifest_schema_version",
) )
if not isinstance(self.sdk_api, VersionRange) or not isinstance(self.protocol, VersionRange): if not isinstance(self.sdk_api, VersionRange) or not isinstance(
raise ValueError("sdk_api and protocol must be explicit VersionRange values") self.protocol, VersionRange
):
raise ValueError(
"sdk_api and protocol must be explicit VersionRange values"
)
if not isinstance(self.workload, WorkloadId): if not isinstance(self.workload, WorkloadId):
raise ValueError("workload must be a WorkloadId") raise ValueError("workload must be a WorkloadId")
object.__setattr__(self, "description", require_string(self.description, "description", max_length=512)) object.__setattr__(
if not isinstance(self.package, PackageSpec) or not isinstance(self.environment, EnvironmentSpec): self,
raise ValueError("manifest package and environment declarations are required") "description",
require_string(self.description, "description", max_length=512),
)
if not isinstance(self.package, PackageSpec) or not isinstance(
self.environment, EnvironmentSpec
):
raise ValueError(
"manifest package and environment declarations are required"
)
schema = freeze_json_mapping(self.parameters_schema, "parameters_schema") schema = freeze_json_mapping(self.parameters_schema, "parameters_schema")
if schema.get("type") != "object" or schema.get("additionalProperties") is not False: if (
raise ValueError("parameters_schema must be an object schema with additionalProperties=false") schema.get("type") != "object"
or schema.get("additionalProperties") is not False
):
raise ValueError(
"parameters_schema must be an object schema with additionalProperties=false"
)
properties = schema.get("properties") properties = schema.get("properties")
if not isinstance(properties, Mapping): if not isinstance(properties, Mapping):
raise ValueError("parameters_schema.properties must be an object") raise ValueError("parameters_schema.properties must be an object")
@@ -231,14 +338,22 @@ class WorkloadManifest:
object.__setattr__(self, "parameters_schema", schema) object.__setattr__(self, "parameters_schema", schema)
if not isinstance(self.workflow, WorkflowSpec): if not isinstance(self.workflow, WorkflowSpec):
raise ValueError("workflow must be a WorkflowSpec") raise ValueError("workflow must be a WorkflowSpec")
object.__setattr__(self, "inputs", _ports(self.inputs, "manifest.inputs", allow_empty=True)) object.__setattr__(
self, "inputs", _ports(self.inputs, "manifest.inputs", allow_empty=True)
)
object.__setattr__(self, "outputs", _ports(self.outputs, "manifest.outputs")) object.__setattr__(self, "outputs", _ports(self.outputs, "manifest.outputs"))
if dict(self.inputs) != dict(self.workflow.inputs): if dict(self.inputs) != dict(self.workflow.inputs):
raise ValueError("manifest inputs must match workflow inputs") raise ValueError("manifest inputs must match workflow inputs")
if dict(self.outputs) != dict(self.workflow.output_ports()): if dict(self.outputs) != dict(self.workflow.output_ports()):
raise ValueError("manifest outputs must match workflow outputs") raise ValueError("manifest outputs must match workflow outputs")
object.__setattr__(self, "determinism", enum_value(DeterminismProfile, self.determinism, "determinism")) object.__setattr__(
modes = tuple(enum_value(TrustMode, mode, "trust_mode") for mode in self.trust_modes) self,
"determinism",
enum_value(DeterminismProfile, self.determinism, "determinism"),
)
modes = tuple(
enum_value(TrustMode, mode, "trust_mode") for mode in self.trust_modes
)
if not modes or len(modes) != len(set(modes)): if not modes or len(modes) != len(set(modes)):
raise ValueError("trust_modes must be non-empty and unique") raise ValueError("trust_modes must be non-empty and unique")
object.__setattr__(self, "trust_modes", modes) object.__setattr__(self, "trust_modes", modes)
@@ -250,9 +365,13 @@ class WorkloadManifest:
} }
for stage in self.workflow.stages: for stage in self.workflow.stages:
if not set(stage.trust_modes).issubset(manifest_mode_values): if not set(stage.trust_modes).issubset(manifest_mode_values):
raise ValueError("stage trust modes must be a subset of manifest trust_modes") raise ValueError(
"stage trust modes must be a subset of manifest trust_modes"
)
if stage.verifier is None: if stage.verifier is None:
raise ValueError("every output-producing stage requires an acceptance verifier") raise ValueError(
"every output-producing stage requires an acceptance verifier"
)
resource_sets = (stage.resources,) + ( resource_sets = (stage.resources,) + (
(stage.gang.per_replica_resources,) if stage.gang is not None else () (stage.gang.per_replica_resources,) if stage.gang is not None else ()
) )
@@ -279,19 +398,26 @@ class WorkloadManifest:
raise ValueError("workflow max_tasks exceeds the workload limit") raise ValueError("workflow max_tasks exceeds the workload limit")
if self.workflow.max_output_bytes > self.limits.max_output_bytes: if self.workflow.max_output_bytes > self.limits.max_output_bytes:
raise ValueError("workflow max_output_bytes exceeds the workload limit") raise ValueError("workflow max_output_bytes exceeds the workload limit")
capabilities = tuple(require_identifier(value, "capability") for value in self.capabilities) capabilities = tuple(
require_identifier(value, "capability") for value in self.capabilities
)
if not capabilities or len(capabilities) != len(set(capabilities)): if not capabilities or len(capabilities) != len(set(capabilities)):
raise ValueError("capabilities must be non-empty and unique") raise ValueError("capabilities must be non-empty and unique")
if self.workload.name not in capabilities: if self.workload.name not in capabilities:
raise ValueError("capabilities must include the canonical workload name") raise ValueError("capabilities must include the canonical workload name")
object.__setattr__(self, "capabilities", capabilities) object.__setattr__(self, "capabilities", capabilities)
profiles = tuple(require_identifier(value, "conformance_profile") for value in self.conformance_profiles) profiles = tuple(
require_identifier(value, "conformance_profile")
for value in self.conformance_profiles
)
if "core-batch-v1" not in profiles or len(profiles) != len(set(profiles)): if "core-batch-v1" not in profiles or len(profiles) != len(set(profiles)):
raise ValueError("conformance_profiles must uniquely include core-batch-v1") raise ValueError("conformance_profiles must uniquely include core-batch-v1")
object.__setattr__(self, "conformance_profiles", profiles) object.__setattr__(self, "conformance_profiles", profiles)
required = tuple(self.required_features) required = tuple(self.required_features)
optional = tuple(self.optional_features) optional = tuple(self.optional_features)
if any(not isinstance(item, FeatureRequirement) for item in required + optional): if any(
not isinstance(item, FeatureRequirement) for item in required + optional
):
raise ValueError("features must contain FeatureRequirement values") raise ValueError("features must contain FeatureRequirement values")
names = [item.name for item in required + optional] names = [item.name for item in required + optional]
if len(names) != len(set(names)): if len(names) != len(set(names)):
@@ -308,23 +434,34 @@ class WorkloadManifest:
if self.determinism is DeterminismProfile.BYTE_EXACT and not exact: if self.determinism is DeterminismProfile.BYTE_EXACT and not exact:
raise ValueError("byte_exact workloads require exact-artifact verifier") raise ValueError("byte_exact workloads require exact-artifact verifier")
if self.determinism is DeterminismProfile.CANONICAL_EXACT and not canonical: if self.determinism is DeterminismProfile.CANONICAL_EXACT and not canonical:
raise ValueError("canonical_exact workloads require canonical-record verifier") raise ValueError(
"canonical_exact workloads require canonical-record verifier"
)
if self.determinism is DeterminismProfile.NUMERIC_TOLERANCE and not numeric: if self.determinism is DeterminismProfile.NUMERIC_TOLERANCE and not numeric:
raise ValueError("numeric_tolerance workloads require numeric-tolerance verifier") raise ValueError(
"numeric_tolerance workloads require numeric-tolerance verifier"
)
if TrustMode.UNTRUSTED_QUORUM in self.trust_modes: if TrustMode.UNTRUSTED_QUORUM in self.trust_modes:
if self.determinism is not DeterminismProfile.BYTE_EXACT or not exact: if self.determinism is not DeterminismProfile.BYTE_EXACT or not exact:
raise ValueError("untrusted_quorum v1 requires byte_exact and exact-artifact") raise ValueError(
if any(stage.kind is StageKind.SIDE_EFFECT for stage in self.workflow.stages): "untrusted_quorum v1 requires byte_exact and exact-artifact"
)
if any(
stage.kind is StageKind.SIDE_EFFECT for stage in self.workflow.stages
):
raise ValueError("side-effect stages cannot use untrusted quorum") raise ValueError("side-effect stages cannot use untrusted quorum")
if self.determinism is DeterminismProfile.SIDE_EFFECTING: if self.determinism is DeterminismProfile.SIDE_EFFECTING:
if self.trust_modes != (TrustMode.TRUSTED,): if self.trust_modes != (TrustMode.TRUSTED,):
raise ValueError("side_effecting workloads must be trusted-only") raise ValueError("side_effecting workloads must be trusted-only")
if not any(stage.kind is StageKind.SIDE_EFFECT for stage in self.workflow.stages): if not any(
stage.kind is StageKind.SIDE_EFFECT for stage in self.workflow.stages
):
raise ValueError("side_effecting workload requires a side-effect stage") raise ValueError("side_effecting workload requires a side-effect stage")
@property @property
def digest(self) -> str: def digest(self) -> str:
import hashlib import hashlib
return hashlib.sha256(self.to_json().encode("utf-8")).hexdigest() return hashlib.sha256(self.to_json().encode("utf-8")).hexdigest()
def to_dict(self) -> dict[str, object]: def to_dict(self) -> dict[str, object]:
@@ -358,21 +495,41 @@ class WorkloadManifest:
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise ValueError("workload manifest must be an object") raise ValueError("workload manifest must be an object")
fields = { fields = {
"manifest_schema_version", "sdk_api", "protocol", "workload", "description", "manifest_schema_version",
"package", "environment", "parameters_schema", "workflow", "inputs", "outputs", "sdk_api",
"determinism", "trust_modes", "verifier", "limits", "capabilities", "protocol",
"conformance_profiles", "required_features", "optional_features", "workload",
"description",
"package",
"environment",
"parameters_schema",
"workflow",
"inputs",
"outputs",
"determinism",
"trust_modes",
"verifier",
"limits",
"capabilities",
"conformance_profiles",
"required_features",
"optional_features",
} }
require_exact_keys(value, fields, "workload manifest") require_exact_keys(value, fields, "workload manifest")
inputs, outputs = value["inputs"], value["outputs"] inputs, outputs = value["inputs"], value["outputs"]
arrays = ( arrays = (
value["trust_modes"], value["capabilities"], value["conformance_profiles"], value["trust_modes"],
value["required_features"], value["optional_features"], value["capabilities"],
value["conformance_profiles"],
value["required_features"],
value["optional_features"],
) )
if not isinstance(inputs, Mapping) or not isinstance(outputs, Mapping): if not isinstance(inputs, Mapping) or not isinstance(outputs, Mapping):
raise ValueError("manifest inputs and outputs must be objects") raise ValueError("manifest inputs and outputs must be objects")
if any(not isinstance(item, list) for item in arrays): if any(not isinstance(item, list) for item in arrays):
raise ValueError("manifest trust, capability, profile, and feature fields must be arrays") raise ValueError(
"manifest trust, capability, profile, and feature fields must be arrays"
)
return cls( return cls(
manifest_schema_version=value["manifest_schema_version"], # type: ignore[arg-type] manifest_schema_version=value["manifest_schema_version"], # type: ignore[arg-type]
sdk_api=VersionRange.from_dict(value["sdk_api"]), sdk_api=VersionRange.from_dict(value["sdk_api"]),
@@ -392,10 +549,12 @@ class WorkloadManifest:
capabilities=tuple(value["capabilities"]), # type: ignore[arg-type] capabilities=tuple(value["capabilities"]), # type: ignore[arg-type]
conformance_profiles=tuple(value["conformance_profiles"]), # type: ignore[arg-type] conformance_profiles=tuple(value["conformance_profiles"]), # type: ignore[arg-type]
required_features=tuple( required_features=tuple(
FeatureRequirement.from_dict(item) for item in value["required_features"] # type: ignore[union-attr] FeatureRequirement.from_dict(item)
for item in value["required_features"] # type: ignore[union-attr]
), ),
optional_features=tuple( optional_features=tuple(
FeatureRequirement.from_dict(item) for item in value["optional_features"] # type: ignore[union-attr] FeatureRequirement.from_dict(item)
for item in value["optional_features"] # type: ignore[union-attr]
), ),
) )
+202 -59
View File
@@ -85,6 +85,12 @@ def _fallbacks(value: object, field: str) -> Mapping[str, str]:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class JobRequest: class JobRequest:
"""A user-requested job: workload identity, strict parameters, and inputs.
Parameters are frozen, JSON-safe, and location-free; required features
must be declared by the workload and available in the runtime.
"""
workload: WorkloadId workload: WorkloadId
parameters: Mapping[str, Any] parameters: Mapping[str, Any]
inputs: Mapping[str, ArtifactCollection] inputs: Mapping[str, ArtifactCollection]
@@ -97,10 +103,15 @@ class JobRequest:
object.__setattr__( object.__setattr__(
self, self,
"parameters", "parameters",
freeze_json_mapping(self.parameters, "job.parameters", forbid_locations=True), freeze_json_mapping(
self.parameters, "job.parameters", forbid_locations=True
),
) )
object.__setattr__(self, "inputs", _collections(self.inputs, "job.inputs")) object.__setattr__(self, "inputs", _collections(self.inputs, "job.inputs"))
features = tuple(require_identifier(value, "required_feature") for value in self.required_features) features = tuple(
require_identifier(value, "required_feature")
for value in self.required_features
)
if len(features) != len(set(features)): if len(features) != len(set(features)):
raise ValueError("required_features must be unique") raise ValueError("required_features must be unique")
object.__setattr__(self, "required_features", features) object.__setattr__(self, "required_features", features)
@@ -112,7 +123,9 @@ class JobRequest:
@property @property
def parameters_digest(self) -> str: def parameters_digest(self) -> str:
return hashlib.sha256(canonical_json(self.parameters).encode("utf-8")).hexdigest() return hashlib.sha256(
canonical_json(self.parameters).encode("utf-8")
).hexdigest()
def to_dict(self) -> dict[str, object]: def to_dict(self) -> dict[str, object]:
return { return {
@@ -135,11 +148,16 @@ class JobRequest:
inputs = value["inputs"] inputs = value["inputs"]
features = value["required_features"] features = value["required_features"]
if not isinstance(inputs, Mapping) or not isinstance(features, list): if not isinstance(inputs, Mapping) or not isinstance(features, list):
raise ValueError("job inputs must be an object and required_features an array") raise ValueError(
"job inputs must be an object and required_features an array"
)
return cls( return cls(
workload=WorkloadId.from_dict(value["workload"]), workload=WorkloadId.from_dict(value["workload"]),
parameters=value["parameters"], # type: ignore[arg-type] parameters=value["parameters"], # type: ignore[arg-type]
inputs={name: ArtifactCollection.from_dict(item) for name, item in inputs.items()}, inputs={
name: ArtifactCollection.from_dict(item)
for name, item in inputs.items()
},
required_features=tuple(features), required_features=tuple(features),
trust_mode=value["trust_mode"], # type: ignore[arg-type] trust_mode=value["trust_mode"], # type: ignore[arg-type]
) )
@@ -155,6 +173,12 @@ class JobRequest:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ValidatedJob: class ValidatedJob:
"""A job after planner validation, carrying the resolved parameter set.
The planner may replace ambiguous parameters (for example a query id)
with their resolved values before tasks are planned.
"""
request: JobRequest request: JobRequest
resolved_parameters: Mapping[str, Any] resolved_parameters: Mapping[str, Any]
@@ -173,7 +197,9 @@ class ValidatedJob:
@property @property
def parameters_digest(self) -> str: def parameters_digest(self) -> str:
return hashlib.sha256(canonical_json(self.resolved_parameters).encode("utf-8")).hexdigest() return hashlib.sha256(
canonical_json(self.resolved_parameters).encode("utf-8")
).hexdigest()
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -201,7 +227,9 @@ class TaskSpec:
schema_version: int = TASK_SCHEMA_VERSION schema_version: int = TASK_SCHEMA_VERSION
def __post_init__(self) -> None: def __post_init__(self) -> None:
require_schema_version(self.schema_version, TASK_SCHEMA_VERSION, "task schema_version") require_schema_version(
self.schema_version, TASK_SCHEMA_VERSION, "task schema_version"
)
if not isinstance(self.workload, WorkloadId): if not isinstance(self.workload, WorkloadId):
raise ValueError("task workload must be a WorkloadId") raise ValueError("task workload must be a WorkloadId")
object.__setattr__( object.__setattr__(
@@ -227,24 +255,32 @@ class TaskSpec:
object.__setattr__( object.__setattr__(
self, self,
"protocol_version", "protocol_version",
require_string(self.protocol_version, "task protocol_version", max_length=32), require_string(
self.protocol_version, "task protocol_version", max_length=32
),
) )
parse_release(self.sdk_api_version, "task sdk_api_version") parse_release(self.sdk_api_version, "task sdk_api_version")
parse_release(self.protocol_version, "task protocol_version") parse_release(self.protocol_version, "task protocol_version")
object.__setattr__( object.__setattr__(
self, self,
"manifest_schema_version", "manifest_schema_version",
require_positive_int(self.manifest_schema_version, "task manifest_schema_version"), require_positive_int(
self.manifest_schema_version, "task manifest_schema_version"
),
) )
object.__setattr__( object.__setattr__(
self, self,
"workflow_schema_version", "workflow_schema_version",
require_positive_int(self.workflow_schema_version, "task workflow_schema_version"), require_positive_int(
self.workflow_schema_version, "task workflow_schema_version"
),
) )
object.__setattr__( object.__setattr__(
self, self,
"environment_digest", "environment_digest",
require_sha256(self.environment_digest, "task environment_digest", prefixed=True), require_sha256(
self.environment_digest, "task environment_digest", prefixed=True
),
) )
if not isinstance(self.verifier, ComponentRef): if not isinstance(self.verifier, ComponentRef):
raise ValueError("task verifier must be a ComponentRef") raise ValueError("task verifier must be a ComponentRef")
@@ -261,14 +297,22 @@ class TaskSpec:
if set(self.selected_features).intersection(self.optional_fallbacks): if set(self.selected_features).intersection(self.optional_fallbacks):
raise ValueError("one task feature cannot be selected and fallbacked") raise ValueError("one task feature cannot be selected and fallbacked")
object.__setattr__(self, "task_key", require_task_key(self.task_key)) object.__setattr__(self, "task_key", require_task_key(self.task_key))
object.__setattr__(self, "stage_id", require_identifier(self.stage_id, "stage_id")) object.__setattr__(
self, "stage_id", require_identifier(self.stage_id, "stage_id")
)
object.__setattr__( object.__setattr__(
self, self,
"parameters", "parameters",
freeze_json_mapping(self.parameters, "task.parameters", forbid_locations=True), freeze_json_mapping(
self.parameters, "task.parameters", forbid_locations=True
),
) )
object.__setattr__(self, "inputs", _collections(self.inputs, "task.inputs")) object.__setattr__(self, "inputs", _collections(self.inputs, "task.inputs"))
object.__setattr__(self, "expected_outputs", _ports(self.expected_outputs, "task.expected_outputs")) object.__setattr__(
self,
"expected_outputs",
_ports(self.expected_outputs, "task.expected_outputs"),
)
if not self.expected_outputs: if not self.expected_outputs:
raise ValueError("task expected_outputs must not be empty") raise ValueError("task expected_outputs must not be empty")
if not isinstance(self.resources, ResourceRequirements): if not isinstance(self.resources, ResourceRequirements):
@@ -283,9 +327,9 @@ class TaskSpec:
canonical_port = require_identifier(port_name, "expected input key port") canonical_port = require_identifier(port_name, "expected input key port")
if not isinstance(keys, (list, tuple)): if not isinstance(keys, (list, tuple)):
raise ValueError("expected input keys must be arrays") raise ValueError("expected input keys must be arrays")
canonical_keys = tuple(sorted( canonical_keys = tuple(
require_identifier(key, "expected input key") for key in keys sorted(require_identifier(key, "expected input key") for key in keys)
)) )
if not canonical_keys or len(canonical_keys) != len(set(canonical_keys)): if not canonical_keys or len(canonical_keys) != len(set(canonical_keys)):
raise ValueError("expected input keys must be non-empty and unique") raise ValueError("expected input keys must be non-empty and unique")
expected_keys[canonical_port] = canonical_keys expected_keys[canonical_port] = canonical_keys
@@ -310,7 +354,9 @@ class TaskSpec:
item.key for item in self.inputs[name].items if item.key is not None item.key for item in self.inputs[name].items if item.key is not None
) )
if set(actual_keys) != set(expected_keys): if set(actual_keys) != set(expected_keys):
raise ValueError("task keyed input does not match its coordinator expected keys") raise ValueError(
"task keyed input does not match its coordinator expected keys"
)
keyed_many_ports = { keyed_many_ports = {
name name
for name, declaration in stage.inputs.items() for name, declaration in stage.inputs.items()
@@ -326,7 +372,9 @@ class TaskSpec:
if self.resources != stage.resources or self.execution != stage.execution: if self.resources != stage.resources or self.execution != stage.execution:
raise ValueError("task execution requirements do not match the stage") raise ValueError("task execution requirements do not match the stage")
if self.verifier != stage.verifier: if self.verifier != stage.verifier:
raise ValueError("task verifier does not match the stage acceptance verifier") raise ValueError(
"task verifier does not match the stage acceptance verifier"
)
if self.trust_mode.value not in stage.trust_modes: if self.trust_mode.value not in stage.trust_modes:
raise ValueError("task trust mode is not allowed by the stage") raise ValueError("task trust mode is not allowed by the stage")
return self return self
@@ -350,7 +398,9 @@ class TaskSpec:
"stage_id": self.stage_id, "stage_id": self.stage_id,
"parameters": thaw_json(self.parameters), "parameters": thaw_json(self.parameters),
"inputs": {name: value.to_dict() for name, value in self.inputs.items()}, "inputs": {name: value.to_dict() for name, value in self.inputs.items()},
"expected_outputs": {name: value.to_dict() for name, value in self.expected_outputs.items()}, "expected_outputs": {
name: value.to_dict() for name, value in self.expected_outputs.items()
},
"resources": self.resources.to_dict(), "resources": self.resources.to_dict(),
"execution": self.execution.to_dict(), "execution": self.execution.to_dict(),
"expected_input_keys": { "expected_input_keys": {
@@ -371,12 +421,27 @@ class TaskSpec:
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise ValueError("task specification must be an object") raise ValueError("task specification must be an object")
fields = { fields = {
"schema_version", "workload", "package_digest", "manifest_digest", "trust_mode", "schema_version",
"sdk_api_version", "protocol_version", "manifest_schema_version", "workload",
"workflow_schema_version", "environment_digest", "verifier", "package_digest",
"selected_features", "optional_fallbacks", "manifest_digest",
"task_key", "stage_id", "parameters", "inputs", "trust_mode",
"expected_outputs", "resources", "execution", "expected_input_keys", "sdk_api_version",
"protocol_version",
"manifest_schema_version",
"workflow_schema_version",
"environment_digest",
"verifier",
"selected_features",
"optional_fallbacks",
"task_key",
"stage_id",
"parameters",
"inputs",
"expected_outputs",
"resources",
"execution",
"expected_input_keys",
} }
require_exact_keys(value, fields, "task specification") require_exact_keys(value, fields, "task specification")
inputs, outputs = value["inputs"], value["expected_outputs"] inputs, outputs = value["inputs"], value["expected_outputs"]
@@ -399,8 +464,13 @@ class TaskSpec:
task_key=value["task_key"], # type: ignore[arg-type] task_key=value["task_key"], # type: ignore[arg-type]
stage_id=value["stage_id"], # type: ignore[arg-type] stage_id=value["stage_id"], # type: ignore[arg-type]
parameters=value["parameters"], # type: ignore[arg-type] parameters=value["parameters"], # type: ignore[arg-type]
inputs={name: ArtifactCollection.from_dict(item) for name, item in inputs.items()}, inputs={
expected_outputs={name: PortSpec.from_dict(item) for name, item in outputs.items()}, name: ArtifactCollection.from_dict(item)
for name, item in inputs.items()
},
expected_outputs={
name: PortSpec.from_dict(item) for name, item in outputs.items()
},
resources=ResourceRequirements.from_dict(value["resources"]), resources=ResourceRequirements.from_dict(value["resources"]),
execution=ExecutionProfile.from_dict(value["execution"]), execution=ExecutionProfile.from_dict(value["execution"]),
expected_input_keys=value["expected_input_keys"], # type: ignore[arg-type] expected_input_keys=value["expected_input_keys"], # type: ignore[arg-type]
@@ -417,6 +487,12 @@ class TaskSpec:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class WorkflowPlan: class WorkflowPlan:
"""The immutable result of planning: tasks plus the exact workload pin.
Every task must carry the same package, manifest, environment, trust
mode, schema versions, and negotiated features as the plan itself.
"""
workload: WorkloadId workload: WorkloadId
package_digest: str package_digest: str
manifest_digest: str manifest_digest: str
@@ -461,24 +537,32 @@ class WorkflowPlan:
object.__setattr__( object.__setattr__(
self, self,
"protocol_version", "protocol_version",
require_string(self.protocol_version, "plan protocol_version", max_length=32), require_string(
self.protocol_version, "plan protocol_version", max_length=32
),
) )
parse_release(self.sdk_api_version, "plan sdk_api_version") parse_release(self.sdk_api_version, "plan sdk_api_version")
parse_release(self.protocol_version, "plan protocol_version") parse_release(self.protocol_version, "plan protocol_version")
object.__setattr__( object.__setattr__(
self, self,
"manifest_schema_version", "manifest_schema_version",
require_positive_int(self.manifest_schema_version, "plan manifest_schema_version"), require_positive_int(
self.manifest_schema_version, "plan manifest_schema_version"
),
) )
object.__setattr__( object.__setattr__(
self, self,
"workflow_schema_version", "workflow_schema_version",
require_positive_int(self.workflow_schema_version, "plan workflow_schema_version"), require_positive_int(
self.workflow_schema_version, "plan workflow_schema_version"
),
) )
object.__setattr__( object.__setattr__(
self, self,
"environment_digest", "environment_digest",
require_sha256(self.environment_digest, "plan environment_digest", prefixed=True), require_sha256(
self.environment_digest, "plan environment_digest", prefixed=True
),
) )
if not isinstance(self.verifier, ComponentRef): if not isinstance(self.verifier, ComponentRef):
raise ValueError("plan verifier must be a ComponentRef") raise ValueError("plan verifier must be a ComponentRef")
@@ -494,7 +578,9 @@ class WorkflowPlan:
) )
if set(self.selected_features).intersection(self.optional_fallbacks): if set(self.selected_features).intersection(self.optional_fallbacks):
raise ValueError("one plan feature cannot be selected and fallbacked") raise ValueError("one plan feature cannot be selected and fallbacked")
object.__setattr__(self, "workflow_id", require_identifier(self.workflow_id, "workflow_id")) object.__setattr__(
self, "workflow_id", require_identifier(self.workflow_id, "workflow_id")
)
object.__setattr__( object.__setattr__(
self, self,
"resolved_parameters", "resolved_parameters",
@@ -524,7 +610,9 @@ class WorkflowPlan:
or task.selected_features != self.selected_features or task.selected_features != self.selected_features
or task.optional_fallbacks != self.optional_fallbacks or task.optional_fallbacks != self.optional_fallbacks
): ):
raise ValueError("workflow plan tasks must carry the plan's exact workload pin") raise ValueError(
"workflow plan tasks must carry the plan's exact workload pin"
)
object.__setattr__(self, "tasks", tasks) object.__setattr__(self, "tasks", tasks)
def validate_workflow(self, workflow: WorkflowSpec) -> "WorkflowPlan": def validate_workflow(self, workflow: WorkflowSpec) -> "WorkflowPlan":
@@ -538,10 +626,14 @@ class WorkflowPlan:
try: try:
task.validate_stage(stages[task.stage_id]) task.validate_stage(stages[task.stage_id])
except KeyError as error: except KeyError as error:
raise ValueError(f"workflow plan references unknown stage: {task.stage_id}") from error raise ValueError(
f"workflow plan references unknown stage: {task.stage_id}"
) from error
task_counts[task.stage_id] = task_counts.get(task.stage_id, 0) + 1 task_counts[task.stage_id] = task_counts.get(task.stage_id, 0) + 1
if task_counts[task.stage_id] > stages[task.stage_id].max_fan_out: if task_counts[task.stage_id] > stages[task.stage_id].max_fan_out:
raise ValueError(f"workflow plan exceeds max_fan_out for stage {task.stage_id}") raise ValueError(
f"workflow plan exceeds max_fan_out for stage {task.stage_id}"
)
return self return self
@property @property
@@ -576,11 +668,22 @@ class WorkflowPlan:
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise ValueError("workflow plan must be an object") raise ValueError("workflow plan must be an object")
fields = { fields = {
"schema_version", "workload", "package_digest", "manifest_digest", "trust_mode", "schema_version",
"sdk_api_version", "protocol_version", "manifest_schema_version", "workload",
"workflow_schema_version", "environment_digest", "verifier", "package_digest",
"selected_features", "optional_fallbacks", "manifest_digest",
"workflow_id", "resolved_parameters", "tasks", "trust_mode",
"sdk_api_version",
"protocol_version",
"manifest_schema_version",
"workflow_schema_version",
"environment_digest",
"verifier",
"selected_features",
"optional_fallbacks",
"workflow_id",
"resolved_parameters",
"tasks",
} }
require_exact_keys(value, fields, "workflow plan") require_exact_keys(value, fields, "workflow plan")
tasks = value["tasks"] tasks = value["tasks"]
@@ -625,14 +728,20 @@ class ExpansionManifest:
schema_version: int = 1 schema_version: int = 1
def __post_init__(self) -> None: def __post_init__(self) -> None:
require_schema_version(self.schema_version, 1, "expansion manifest schema_version") require_schema_version(
object.__setattr__(self, "job_id", require_uuid(self.job_id, "expansion job_id")) self.schema_version, 1, "expansion manifest schema_version"
)
object.__setattr__(
self, "job_id", require_uuid(self.job_id, "expansion job_id")
)
object.__setattr__( object.__setattr__(
self, self,
"parent_task_id", "parent_task_id",
require_uuid(self.parent_task_id, "expansion parent_task_id"), require_uuid(self.parent_task_id, "expansion parent_task_id"),
) )
object.__setattr__(self, "parent_task_key", require_task_key(self.parent_task_key)) object.__setattr__(
self, "parent_task_key", require_task_key(self.parent_task_key)
)
object.__setattr__( object.__setattr__(
self, self,
"parent_execution_contract_digest", "parent_execution_contract_digest",
@@ -641,15 +750,23 @@ class ExpansionManifest:
"expansion parent_execution_contract_digest", "expansion parent_execution_contract_digest",
), ),
) )
object.__setattr__(self, "max_children", require_positive_int(self.max_children, "max_children")) object.__setattr__(
self,
"max_children",
require_positive_int(self.max_children, "max_children"),
)
tasks = tuple(self.tasks) tasks = tuple(self.tasks)
if not tasks or len(tasks) > self.max_children: if not tasks or len(tasks) > self.max_children:
raise ValueError("expansion tasks must be non-empty and within max_children") raise ValueError(
"expansion tasks must be non-empty and within max_children"
)
keys = [task.task_key for task in tasks] keys = [task.task_key for task in tasks]
if keys != sorted(keys) or len(keys) != len(set(keys)): if keys != sorted(keys) or len(keys) != len(set(keys)):
raise ValueError("expansion child task keys must be unique and ascending") raise ValueError("expansion child task keys must be unique and ascending")
if any(not key.startswith(self.parent_task_key + "/") for key in keys): if any(not key.startswith(self.parent_task_key + "/") for key in keys):
raise ValueError("expansion child task keys must be namespaced by the parent") raise ValueError(
"expansion child task keys must be namespaced by the parent"
)
first = tasks[0] first = tasks[0]
if any( if any(
task.workload != first.workload task.workload != first.workload
@@ -691,7 +808,9 @@ class ExpansionManifest:
raise ValueError("expansion workflow must be a WorkflowSpec") raise ValueError("expansion workflow must be a WorkflowSpec")
if self.job_id != require_uuid(job_id, "coordinator job_id"): if self.job_id != require_uuid(job_id, "coordinator job_id"):
raise ValueError("expansion belongs to another job") raise ValueError("expansion belongs to another job")
if self.parent_task_id != require_uuid(parent_task_id, "coordinator parent_task_id"): if self.parent_task_id != require_uuid(
parent_task_id, "coordinator parent_task_id"
):
raise ValueError("expansion belongs to another durable parent task") raise ValueError("expansion belongs to another durable parent task")
if self.parent_task_key != parent.task_key: if self.parent_task_key != parent.task_key:
raise ValueError("expansion parent task key does not match") raise ValueError("expansion parent task key does not match")
@@ -703,7 +822,9 @@ class ExpansionManifest:
try: try:
parent_stage = stages[parent.stage_id] parent_stage = stages[parent.stage_id]
except KeyError as error: except KeyError as error:
raise ValueError("expansion parent references an unknown workflow stage") from error raise ValueError(
"expansion parent references an unknown workflow stage"
) from error
parent.validate_stage(parent_stage) parent.validate_stage(parent_stage)
if parent_stage.kind is not StageKind.PLAN: if parent_stage.kind is not StageKind.PLAN:
raise ValueError("v1 expansion parent must be a plan stage") raise ValueError("v1 expansion parent must be a plan stage")
@@ -726,8 +847,12 @@ class ExpansionManifest:
for port_name, collection in ports.items(): for port_name, collection in ports.items():
canonical_port = require_identifier(port_name, "authorized input port") canonical_port = require_identifier(port_name, "authorized input port")
declaration = stages[canonical_stage].inputs.get(canonical_port) declaration = stages[canonical_stage].inputs.get(canonical_port)
if declaration is None or not isinstance(collection, ArtifactCollection): if declaration is None or not isinstance(
raise ValueError("authorized_inputs references an unknown input port") collection, ArtifactCollection
):
raise ValueError(
"authorized_inputs references an unknown input port"
)
declaration.validate_collection( declaration.validate_collection(
collection, collection,
f"authorized input {canonical_stage}.{canonical_port}", f"authorized input {canonical_stage}.{canonical_port}",
@@ -762,23 +887,34 @@ class ExpansionManifest:
or task.selected_features != parent.selected_features or task.selected_features != parent.selected_features
or task.optional_fallbacks != parent.optional_fallbacks or task.optional_fallbacks != parent.optional_fallbacks
): ):
raise ValueError("expansion child task does not share the parent workload pin") raise ValueError(
"expansion child task does not share the parent workload pin"
)
try: try:
stage = stages[task.stage_id] stage = stages[task.stage_id]
except KeyError as error: except KeyError as error:
raise ValueError("expansion child references an unknown workflow stage") from error raise ValueError(
"expansion child references an unknown workflow stage"
) from error
if parent.stage_id not in stage.needs: if parent.stage_id not in stage.needs:
raise ValueError("v1 expansion child must be a direct successor of its parent stage") raise ValueError(
"v1 expansion child must be a direct successor of its parent stage"
)
task.validate_stage(stage) task.validate_stage(stage)
target_ports = allowed_by_target.get(task.stage_id, {}) target_ports = allowed_by_target.get(task.stage_id, {})
for port_name, collection in task.inputs.items(): for port_name, collection in task.inputs.items():
allowed = target_ports.get(port_name) allowed = target_ports.get(port_name)
if allowed is None or collection.kind is not allowed.kind: if allowed is None or collection.kind is not allowed.kind:
raise ValueError("expansion child input target is not coordinator-authorized") raise ValueError(
"expansion child input target is not coordinator-authorized"
)
if collection.kind is CollectionKind.ORDERED: if collection.kind is CollectionKind.ORDERED:
cursor = 0 cursor = 0
for item in collection.items: for item in collection.items:
while cursor < len(allowed.items) and allowed.items[cursor] != item: while (
cursor < len(allowed.items)
and allowed.items[cursor] != item
):
cursor += 1 cursor += 1
if cursor == len(allowed.items): if cursor == len(allowed.items):
raise ValueError( raise ValueError(
@@ -798,7 +934,9 @@ class ExpansionManifest:
@property @property
def digest(self) -> str: def digest(self) -> str:
return hashlib.sha256(canonical_json(self.to_dict()).encode("utf-8")).hexdigest() return hashlib.sha256(
canonical_json(self.to_dict()).encode("utf-8")
).hexdigest()
def to_dict(self) -> dict[str, object]: def to_dict(self) -> dict[str, object]:
return { return {
@@ -819,8 +957,13 @@ class ExpansionManifest:
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise ValueError("expansion manifest must be an object") raise ValueError("expansion manifest must be an object")
fields = { fields = {
"schema_version", "job_id", "parent_task_id", "parent_task_key", "schema_version",
"parent_execution_contract_digest", "max_children", "tasks", "job_id",
"parent_task_id",
"parent_task_key",
"parent_execution_contract_digest",
"max_children",
"tasks",
} }
require_exact_keys(value, fields, "expansion manifest") require_exact_keys(value, fields, "expansion manifest")
tasks = value["tasks"] tasks = value["tasks"]
+34 -1
View File
@@ -5,7 +5,14 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Mapping, Protocol, Sequence from typing import Mapping, Protocol, Sequence
from .artifacts import ArtifactCollection, ArtifactRef, ArtifactSchema, OutputManifest, Provenance from .artifacts import (
ArtifactCollection,
ArtifactRef,
ArtifactSchema,
OutputManifest,
Provenance,
)
from .identity import ComponentRef
from .plans import JobRequest, TaskSpec, ValidatedJob, WorkflowPlan from .plans import JobRequest, TaskSpec, ValidatedJob, WorkflowPlan
from .runtime import NegotiatedWorkload from .runtime import NegotiatedWorkload
from .verification import CandidateOutputs, VerificationDecision, VerifyContext from .verification import CandidateOutputs, VerificationDecision, VerifyContext
@@ -16,6 +23,7 @@ class ArtifactCatalog(Protocol):
def materialize(self, artifact: ArtifactRef) -> Path: def materialize(self, artifact: ArtifactRef) -> Path:
"""Return an attempt-scoped verified local copy without exposing credentials.""" """Return an attempt-scoped verified local copy without exposing credentials."""
...
class ArtifactSink(Protocol): class ArtifactSink(Protocol):
@@ -30,9 +38,12 @@ class ArtifactSink(Protocol):
dimensions: tuple[int, ...] = (), dimensions: tuple[int, ...] = (),
) -> ArtifactRef: ) -> ArtifactRef:
"""Validate/upload bytes and return coordinator-owned immutable metadata.""" """Validate/upload bytes and return coordinator-owned immutable metadata."""
...
class CancellationToken(Protocol): class CancellationToken(Protocol):
"""Cooperative cancellation observable by workload handlers."""
def cancelled(self) -> bool: ... def cancelled(self) -> bool: ...
def raise_if_cancelled(self) -> None: ... def raise_if_cancelled(self) -> None: ...
@@ -57,9 +68,13 @@ class PlanningContext(PlanningResources, Protocol):
@property @property
def negotiated(self) -> NegotiatedWorkload: def negotiated(self) -> NegotiatedWorkload:
"""Resolved optional fallbacks and the exact negotiated manifest.""" """Resolved optional fallbacks and the exact negotiated manifest."""
...
class TaskContext(Protocol): class TaskContext(Protocol):
"""Everything a runner needs: the pinned task, scoped catalog/sink,
workspace, cancellation, and provenance to stamp on outputs."""
@property @property
def task(self) -> TaskSpec: ... def task(self) -> TaskSpec: ...
@@ -80,11 +95,18 @@ class TaskContext(Protocol):
class ReduceContext(TaskContext, Protocol): class ReduceContext(TaskContext, Protocol):
"""TaskContext plus the keyed partial artifacts accepted for reduction."""
@property @property
def accepted_inputs(self) -> Mapping[str, ArtifactCollection]: ... def accepted_inputs(self) -> Mapping[str, ArtifactCollection]: ...
class Planner(Protocol): class Planner(Protocol):
"""Validates a job and produces a digest-pinned ``WorkflowPlan``.
``entry_point`` must match the workflow's PLAN stage when one exists.
"""
entry_point: str entry_point: str
def validate(self, request: JobRequest) -> ValidatedJob: ... def validate(self, request: JobRequest) -> ValidatedJob: ...
@@ -93,14 +115,25 @@ class Planner(Protocol):
class Runner(Protocol): class Runner(Protocol):
"""Executes one map (or verify) task and seals its partial output."""
def run(self, context: TaskContext) -> OutputManifest: ... def run(self, context: TaskContext) -> OutputManifest: ...
class Reducer(Protocol): class Reducer(Protocol):
"""Merges accepted partial artifacts into the final result."""
def reduce(self, context: ReduceContext) -> OutputManifest: ... def reduce(self, context: ReduceContext) -> OutputManifest: ...
class Verifier(Protocol): class Verifier(Protocol):
"""Accepts or rejects candidate outputs with bounded sanitized evidence.
``identity`` must match the key under which the verifier is registered.
"""
identity: ComponentRef
def verify( def verify(
self, self,
context: VerifyContext, context: VerifyContext,
+25 -13
View File
@@ -78,7 +78,7 @@ def _validate_entry_point_ownership(entry_point: metadata.EntryPoint) -> None:
if normalized_owners and normalized_owners != {expected_owner}: if normalized_owners and normalized_owners != {expected_owner}:
raise ValueError("workload entry point top-level package is not uniquely owned") raise ValueError("workload entry point top-level package is not uniquely owned")
package_root = Path(distribution.locate_file(root_name)) package_root = Path(str(distribution.locate_file(root_name)))
if not package_root.exists(): if not package_root.exists():
root_spec = util.find_spec(root_name) root_spec = util.find_spec(root_name)
locations = ( locations = (
@@ -109,8 +109,8 @@ def _validate_entry_point_ownership(entry_point: metadata.EntryPoint) -> None:
else: else:
if len(parts) != 1: if len(parts) != 1:
raise ValueError("workload entry point module is outside its distribution") raise ValueError("workload entry point module is outside its distribution")
ownership_root = Path(distribution.locate_file(".")).resolve() ownership_root = Path(str(distribution.locate_file("."))).resolve()
module_base = Path(distribution.locate_file(root_name)) module_base = Path(str(distribution.locate_file(root_name)))
candidates = [ candidates = [
*(Path(str(module_base) + suffix) for suffix in machinery.SOURCE_SUFFIXES), *(Path(str(module_base) + suffix) for suffix in machinery.SOURCE_SUFFIXES),
*( *(
@@ -125,6 +125,14 @@ def _validate_entry_point_ownership(entry_point: metadata.EntryPoint) -> None:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class WorkloadDefinition: class WorkloadDefinition:
"""The immutable binding of a manifest to its installed handlers.
Validation at construction requires every stage entry point to have a
matching runner/reducer, the manifest verifier to be installed with
matching configuration, and verifier handler identities to match their
keys.
"""
manifest: WorkloadManifest manifest: WorkloadManifest
planner: Planner planner: Planner
runners: Mapping[str, Runner] runners: Mapping[str, Runner]
@@ -215,6 +223,12 @@ class WorkloadDefinition:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class AllowedPackage: class AllowedPackage:
"""An administrator's approval to load one installed workload version.
Pins the distribution, the exact ``WorkloadId``, and the measured
``sha256:`` package digest that discovery must match.
"""
distribution: str distribution: str
workload: WorkloadId workload: WorkloadId
digest: str digest: str
@@ -272,6 +286,8 @@ def workload_allowlist_from_json(value: object) -> tuple[AllowedPackage, ...]:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class WorkloadDescription: class WorkloadDescription:
"""A read-only registry listing: identity, description, digest, and enablement."""
workload: WorkloadId workload: WorkloadId
description: str description: str
package_digest: str package_digest: str
@@ -581,11 +597,10 @@ class WorkloadRegistry:
discovered: set[tuple[str, str, str]] = set() discovered: set[tuple[str, str, str]] = set()
pending: list[WorkloadDefinition] = [] pending: list[WorkloadDefinition] = []
for entry_point in selected: for entry_point in selected:
distribution = ( entry_dist = entry_point.dist
_normalized_distribution_name(entry_point.dist.name) if entry_dist is None:
if entry_point.dist continue
else "" distribution = _normalized_distribution_name(entry_dist.name)
)
for key, approval in allowed.items(): for key, approval in allowed.items():
if _normalized_distribution_name(key[0]) != distribution: if _normalized_distribution_name(key[0]) != distribution:
continue continue
@@ -606,7 +621,7 @@ class WorkloadRegistry:
prefix="scimesh-discovery-cache-" prefix="scimesh-discovery-cache-"
) as cache_prefix, ) as cache_prefix,
): ):
measured_before = installed_distribution_digest(entry_point.dist) measured_before = installed_distribution_digest(entry_dist)
if measured_before != approval.digest: if measured_before != approval.digest:
raise ValueError( raise ValueError(
"installed package content does not match its allowlist digest" "installed package content does not match its allowlist digest"
@@ -626,10 +641,7 @@ class WorkloadRegistry:
finally: finally:
sys.pycache_prefix = previous_cache_prefix sys.pycache_prefix = previous_cache_prefix
sys.dont_write_bytecode = previous_bytecode_policy sys.dont_write_bytecode = previous_bytecode_policy
if ( if installed_distribution_digest(entry_dist) != measured_before:
installed_distribution_digest(entry_point.dist)
!= measured_before
):
raise ValueError( raise ValueError(
"installed package content changed while loading its entry point" "installed package content changed while loading its entry point"
) )
+270 -66
View File
@@ -24,6 +24,8 @@ from ._validation import (
class AcceleratorMode(str, Enum): class AcceleratorMode(str, Enum):
"""How an accelerator is allocated: whole device or a managed partition."""
NONE = "none" NONE = "none"
EXCLUSIVE_DEVICE = "exclusive_device" EXCLUSIVE_DEVICE = "exclusive_device"
FRACTIONAL = "fractional" FRACTIONAL = "fractional"
@@ -36,6 +38,12 @@ def _resource_id(value: object, field: str) -> str:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class AcceleratorDevice: class AcceleratorDevice:
"""One physical accelerator advertised in a host inventory.
Declared but not schedulable until a runtime advertises the matching
accelerator features.
"""
kind: str kind: str
vendor: str vendor: str
device_id: str device_id: str
@@ -48,27 +56,55 @@ class AcceleratorDevice:
healthy: bool = True healthy: bool = True
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__(self, "kind", require_identifier(self.kind, "accelerator.kind")) object.__setattr__(
object.__setattr__(self, "vendor", require_identifier(self.vendor, "accelerator.vendor")) self, "kind", require_identifier(self.kind, "accelerator.kind")
object.__setattr__(self, "device_id", _resource_id(self.device_id, "accelerator.device_id")) )
object.__setattr__(self, "model", require_string(self.model, "accelerator.model", max_length=160)) object.__setattr__(
object.__setattr__(self, "memory_mb", require_positive_int(self.memory_mb, "accelerator.memory_mb")) self, "vendor", require_identifier(self.vendor, "accelerator.vendor")
modes = tuple(enum_value(AcceleratorMode, mode, "accelerator.mode") for mode in self.modes) )
object.__setattr__(
self, "device_id", _resource_id(self.device_id, "accelerator.device_id")
)
object.__setattr__(
self,
"model",
require_string(self.model, "accelerator.model", max_length=160),
)
object.__setattr__(
self,
"memory_mb",
require_positive_int(self.memory_mb, "accelerator.memory_mb"),
)
modes = tuple(
enum_value(AcceleratorMode, mode, "accelerator.mode") for mode in self.modes
)
if not modes or AcceleratorMode.NONE in modes or len(modes) != len(set(modes)): if not modes or AcceleratorMode.NONE in modes or len(modes) != len(set(modes)):
raise ValueError("accelerator modes must contain unique allocation modes other than none") raise ValueError(
"accelerator modes must contain unique allocation modes other than none"
)
object.__setattr__(self, "modes", modes) object.__setattr__(self, "modes", modes)
capabilities = freeze_json_mapping(self.capabilities, "accelerator.capabilities") capabilities = freeze_json_mapping(
self.capabilities, "accelerator.capabilities"
)
if any(not isinstance(value, str) for value in capabilities.values()): if any(not isinstance(value, str) for value in capabilities.values()):
raise ValueError("accelerator capabilities must use string values") raise ValueError("accelerator capabilities must use string values")
object.__setattr__(self, "capabilities", capabilities) object.__setattr__(self, "capabilities", capabilities)
if self.topology_group is not None: if self.topology_group is not None:
object.__setattr__(self, "topology_group", _resource_id(self.topology_group, "topology_group")) object.__setattr__(
self,
"topology_group",
_resource_id(self.topology_group, "topology_group"),
)
if self.partition_id is not None: if self.partition_id is not None:
object.__setattr__(self, "partition_id", _resource_id(self.partition_id, "partition_id")) object.__setattr__(
self, "partition_id", _resource_id(self.partition_id, "partition_id")
)
if AcceleratorMode.PARTITION not in modes: if AcceleratorMode.PARTITION not in modes:
raise ValueError("a partition_id requires partition allocation support") raise ValueError("a partition_id requires partition allocation support")
if AcceleratorMode.EXCLUSIVE_DEVICE in modes: if AcceleratorMode.EXCLUSIVE_DEVICE in modes:
raise ValueError("an accelerator partition cannot be allocated as a whole device") raise ValueError(
"an accelerator partition cannot be allocated as a whole device"
)
if not isinstance(self.healthy, bool): if not isinstance(self.healthy, bool):
raise ValueError("accelerator.healthy must be a boolean") raise ValueError("accelerator.healthy must be a boolean")
@@ -95,8 +131,16 @@ class AcceleratorDevice:
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise ValueError("accelerator device must be an object") raise ValueError("accelerator device must be an object")
fields = { fields = {
"kind", "vendor", "device_id", "model", "memory_mb", "modes", "kind",
"capabilities", "topology_group", "partition_id", "healthy", "vendor",
"device_id",
"model",
"memory_mb",
"modes",
"capabilities",
"topology_group",
"partition_id",
"healthy",
} }
require_exact_keys(value, fields, "accelerator device") require_exact_keys(value, fields, "accelerator device")
modes = value["modes"] modes = value["modes"]
@@ -118,6 +162,8 @@ class AcceleratorDevice:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ResourceInventory: class ResourceInventory:
"""What a host offers: CPU, memory, scratch, architecture, environments, accelerators."""
cpu_cores: int cpu_cores: int
memory_mb: int memory_mb: int
scratch_mb: int scratch_mb: int
@@ -126,13 +172,31 @@ class ResourceInventory:
environment_digests: tuple[str, ...] = () environment_digests: tuple[str, ...] = ()
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__(self, "cpu_cores", require_positive_int(self.cpu_cores, "inventory.cpu_cores")) object.__setattr__(
object.__setattr__(self, "memory_mb", require_positive_int(self.memory_mb, "inventory.memory_mb")) self,
object.__setattr__(self, "scratch_mb", require_nonnegative_int(self.scratch_mb, "inventory.scratch_mb")) "cpu_cores",
object.__setattr__(self, "architecture", require_identifier(self.architecture, "inventory.architecture")) require_positive_int(self.cpu_cores, "inventory.cpu_cores"),
)
object.__setattr__(
self,
"memory_mb",
require_positive_int(self.memory_mb, "inventory.memory_mb"),
)
object.__setattr__(
self,
"scratch_mb",
require_nonnegative_int(self.scratch_mb, "inventory.scratch_mb"),
)
object.__setattr__(
self,
"architecture",
require_identifier(self.architecture, "inventory.architecture"),
)
devices = tuple(self.accelerators) devices = tuple(self.accelerators)
if any(not isinstance(device, AcceleratorDevice) for device in devices): if any(not isinstance(device, AcceleratorDevice) for device in devices):
raise ValueError("inventory accelerators must contain AcceleratorDevice values") raise ValueError(
"inventory accelerators must contain AcceleratorDevice values"
)
ids = [device.allocation_id for device in devices] ids = [device.allocation_id for device in devices]
if len(ids) != len(set(ids)): if len(ids) != len(set(ids)):
raise ValueError("inventory accelerator allocation IDs must be unique") raise ValueError("inventory accelerator allocation IDs must be unique")
@@ -160,26 +224,40 @@ class ResourceInventory:
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise ValueError("resource inventory must be an object") raise ValueError("resource inventory must be an object")
fields = { fields = {
"cpu_cores", "memory_mb", "scratch_mb", "architecture", "cpu_cores",
"accelerators", "environment_digests", "memory_mb",
"scratch_mb",
"architecture",
"accelerators",
"environment_digests",
} }
require_exact_keys(value, fields, "resource inventory") require_exact_keys(value, fields, "resource inventory")
accelerators = value["accelerators"] accelerators = value["accelerators"]
digests = value["environment_digests"] digests = value["environment_digests"]
if not isinstance(accelerators, list) or not isinstance(digests, list): if not isinstance(accelerators, list) or not isinstance(digests, list):
raise ValueError("inventory accelerators and environment_digests must be arrays") raise ValueError(
"inventory accelerators and environment_digests must be arrays"
)
return cls( return cls(
cpu_cores=value["cpu_cores"], # type: ignore[arg-type] cpu_cores=value["cpu_cores"], # type: ignore[arg-type]
memory_mb=value["memory_mb"], # type: ignore[arg-type] memory_mb=value["memory_mb"], # type: ignore[arg-type]
scratch_mb=value["scratch_mb"], # type: ignore[arg-type] scratch_mb=value["scratch_mb"], # type: ignore[arg-type]
architecture=value["architecture"], # type: ignore[arg-type] architecture=value["architecture"], # type: ignore[arg-type]
accelerators=tuple(AcceleratorDevice.from_dict(device) for device in accelerators), accelerators=tuple(
AcceleratorDevice.from_dict(device) for device in accelerators
),
environment_digests=tuple(digests), environment_digests=tuple(digests),
) )
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ResourceRequirements: class ResourceRequirements:
"""What one task needs; eligibility is checked against the inventory.
``cpu_cores`` is a reservation, never a concurrency claim; accelerator
declarations remain fail-closed until runtime support exists.
"""
profile: str profile: str
cpu_cores: int cpu_cores: int
memory_mb: int memory_mb: int
@@ -196,55 +274,107 @@ class ResourceRequirements:
max_duration_seconds: int = 3600 max_duration_seconds: int = 3600
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__(self, "profile", require_identifier(self.profile, "resources.profile")) object.__setattr__(
object.__setattr__(self, "cpu_cores", require_positive_int(self.cpu_cores, "resources.cpu_cores")) self, "profile", require_identifier(self.profile, "resources.profile")
object.__setattr__(self, "memory_mb", require_positive_int(self.memory_mb, "resources.memory_mb")) )
object.__setattr__(self, "scratch_mb", require_nonnegative_int(self.scratch_mb, "resources.scratch_mb")) object.__setattr__(
self,
"cpu_cores",
require_positive_int(self.cpu_cores, "resources.cpu_cores"),
)
object.__setattr__(
self,
"memory_mb",
require_positive_int(self.memory_mb, "resources.memory_mb"),
)
object.__setattr__(
self,
"scratch_mb",
require_nonnegative_int(self.scratch_mb, "resources.scratch_mb"),
)
object.__setattr__( object.__setattr__(
self, self,
"accelerator_count", "accelerator_count",
require_nonnegative_int(self.accelerator_count, "resources.accelerator_count"), require_nonnegative_int(
self.accelerator_count, "resources.accelerator_count"
),
) )
object.__setattr__( object.__setattr__(
self, self,
"accelerator_memory_mb", "accelerator_memory_mb",
require_nonnegative_int(self.accelerator_memory_mb, "resources.accelerator_memory_mb"), require_nonnegative_int(
self.accelerator_memory_mb, "resources.accelerator_memory_mb"
),
) )
object.__setattr__( object.__setattr__(
self, self,
"accelerator_mode", "accelerator_mode",
enum_value(AcceleratorMode, self.accelerator_mode, "resources.accelerator_mode"), enum_value(
AcceleratorMode, self.accelerator_mode, "resources.accelerator_mode"
),
) )
if self.accelerator_count == 0: if self.accelerator_count == 0:
if self.accelerator_kind is not None or self.accelerator_memory_mb or self.accelerator_mode is not AcceleratorMode.NONE: if (
raise ValueError("CPU-only resources must not declare accelerator constraints") self.accelerator_kind is not None
or self.accelerator_memory_mb
or self.accelerator_mode is not AcceleratorMode.NONE
):
raise ValueError(
"CPU-only resources must not declare accelerator constraints"
)
if self.topology_group is not None: if self.topology_group is not None:
raise ValueError("CPU-only resources must not declare accelerator topology") raise ValueError(
"CPU-only resources must not declare accelerator topology"
)
else: else:
if self.accelerator_kind is None: if self.accelerator_kind is None:
raise ValueError("accelerator_kind is required when accelerator_count is non-zero") raise ValueError(
object.__setattr__(self, "accelerator_kind", require_identifier(self.accelerator_kind, "accelerator_kind")) "accelerator_kind is required when accelerator_count is non-zero"
)
object.__setattr__(
self,
"accelerator_kind",
require_identifier(self.accelerator_kind, "accelerator_kind"),
)
if self.accelerator_mode is AcceleratorMode.NONE: if self.accelerator_mode is AcceleratorMode.NONE:
raise ValueError("accelerator_mode is required when accelerator_count is non-zero") raise ValueError(
"accelerator_mode is required when accelerator_count is non-zero"
)
if self.architecture is not None: if self.architecture is not None:
object.__setattr__(self, "architecture", require_identifier(self.architecture, "resources.architecture")) object.__setattr__(
self,
"architecture",
require_identifier(self.architecture, "resources.architecture"),
)
if self.topology_group is not None: if self.topology_group is not None:
object.__setattr__(self, "topology_group", _resource_id(self.topology_group, "resources.topology_group")) object.__setattr__(
self,
"topology_group",
_resource_id(self.topology_group, "resources.topology_group"),
)
if self.environment_digest is not None: if self.environment_digest is not None:
object.__setattr__( object.__setattr__(
self, self,
"environment_digest", "environment_digest",
require_sha256(self.environment_digest, "resources.environment_digest", prefixed=True), require_sha256(
self.environment_digest,
"resources.environment_digest",
prefixed=True,
),
) )
object.__setattr__( object.__setattr__(
self, self,
"estimated_input_bytes", "estimated_input_bytes",
require_nonnegative_int(self.estimated_input_bytes, "estimated_input_bytes"), require_nonnegative_int(
self.estimated_input_bytes, "estimated_input_bytes"
),
) )
object.__setattr__( object.__setattr__(
self, self,
"estimated_output_bytes", "estimated_output_bytes",
require_nonnegative_int(self.estimated_output_bytes, "estimated_output_bytes"), require_nonnegative_int(
self.estimated_output_bytes, "estimated_output_bytes"
),
) )
object.__setattr__( object.__setattr__(
self, self,
@@ -260,9 +390,15 @@ class ResourceRequirements:
errors.append("insufficient-memory") errors.append("insufficient-memory")
if self.scratch_mb > inventory.scratch_mb: if self.scratch_mb > inventory.scratch_mb:
errors.append("insufficient-scratch") errors.append("insufficient-scratch")
if self.architecture is not None and self.architecture != inventory.architecture: if (
self.architecture is not None
and self.architecture != inventory.architecture
):
errors.append("architecture-mismatch") errors.append("architecture-mismatch")
if self.environment_digest is not None and self.environment_digest not in inventory.environment_digests: if (
self.environment_digest is not None
and self.environment_digest not in inventory.environment_digests
):
errors.append("environment-unavailable") errors.append("environment-unavailable")
matches = self._matching_devices(inventory.accelerators) matches = self._matching_devices(inventory.accelerators)
if len(matches) < self.accelerator_count: if len(matches) < self.accelerator_count:
@@ -286,22 +422,35 @@ class ResourceRequirements:
and device.memory_mb >= self.accelerator_memory_mb and device.memory_mb >= self.accelerator_memory_mb
and self.accelerator_mode in device.modes and self.accelerator_mode in device.modes
and ( and (
(self.accelerator_mode is AcceleratorMode.PARTITION and device.partition_id is not None) (
self.accelerator_mode is AcceleratorMode.PARTITION
and device.partition_id is not None
)
or ( or (
self.accelerator_mode is AcceleratorMode.EXCLUSIVE_DEVICE self.accelerator_mode is AcceleratorMode.EXCLUSIVE_DEVICE
and device.partition_id is None and device.partition_id is None
) )
or self.accelerator_mode is AcceleratorMode.FRACTIONAL or self.accelerator_mode is AcceleratorMode.FRACTIONAL
) )
and (self.topology_group is None or device.topology_group == self.topology_group) and (
self.topology_group is None
or device.topology_group == self.topology_group
)
] ]
if self.accelerator_count > 1 and self.topology_group is None: if self.accelerator_count > 1 and self.topology_group is None:
groups: dict[str | None, list[AcceleratorDevice]] = {} groups: dict[str | None, list[AcceleratorDevice]] = {}
for device in matches: for device in matches:
groups.setdefault(device.topology_group, []).append(device) groups.setdefault(device.topology_group, []).append(device)
sufficiently_large = [group for group in groups.values() if len(group) >= self.accelerator_count] sufficiently_large = [
group
for group in groups.values()
if len(group) >= self.accelerator_count
]
if sufficiently_large: if sufficiently_large:
matches = min(sufficiently_large, key=lambda group: tuple(item.allocation_id for item in group)) matches = min(
sufficiently_large,
key=lambda group: tuple(item.allocation_id for item in group),
)
return tuple(sorted(matches, key=lambda device: device.allocation_id)) return tuple(sorted(matches, key=lambda device: device.allocation_id))
def to_dict(self) -> dict[str, object]: def to_dict(self) -> dict[str, object]:
@@ -327,10 +476,20 @@ class ResourceRequirements:
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise ValueError("resource requirements must be an object") raise ValueError("resource requirements must be an object")
fields = { fields = {
"profile", "cpu_cores", "memory_mb", "scratch_mb", "accelerator_count", "profile",
"accelerator_kind", "accelerator_memory_mb", "accelerator_mode", "architecture", "cpu_cores",
"topology_group", "environment_digest", "estimated_input_bytes", "memory_mb",
"estimated_output_bytes", "max_duration_seconds", "scratch_mb",
"accelerator_count",
"accelerator_kind",
"accelerator_memory_mb",
"accelerator_mode",
"architecture",
"topology_group",
"environment_digest",
"estimated_input_bytes",
"estimated_output_bytes",
"max_duration_seconds",
} }
require_exact_keys(value, fields, "resource requirements") require_exact_keys(value, fields, "resource requirements")
return cls(**value) # type: ignore[arg-type] return cls(**value) # type: ignore[arg-type]
@@ -346,16 +505,32 @@ class ResourceAllocation:
accelerator_ids: tuple[str, ...] accelerator_ids: tuple[str, ...]
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__(self, "allocation_id", _resource_id(self.allocation_id, "allocation_id")) object.__setattr__(
self, "allocation_id", _resource_id(self.allocation_id, "allocation_id")
)
object.__setattr__( object.__setattr__(
self, self,
"owner_id", "owner_id",
require_string(self.owner_id, "reservation owner_id", max_length=256), require_string(self.owner_id, "reservation owner_id", max_length=256),
) )
object.__setattr__(self, "cpu_cores", require_positive_int(self.cpu_cores, "allocation.cpu_cores")) object.__setattr__(
object.__setattr__(self, "memory_mb", require_positive_int(self.memory_mb, "allocation.memory_mb")) self,
object.__setattr__(self, "scratch_mb", require_nonnegative_int(self.scratch_mb, "allocation.scratch_mb")) "cpu_cores",
ids = tuple(_resource_id(value, "accelerator_id") for value in self.accelerator_ids) require_positive_int(self.cpu_cores, "allocation.cpu_cores"),
)
object.__setattr__(
self,
"memory_mb",
require_positive_int(self.memory_mb, "allocation.memory_mb"),
)
object.__setattr__(
self,
"scratch_mb",
require_nonnegative_int(self.scratch_mb, "allocation.scratch_mb"),
)
ids = tuple(
_resource_id(value, "accelerator_id") for value in self.accelerator_ids
)
if len(ids) != len(set(ids)): if len(ids) != len(set(ids)):
raise ValueError("accelerator_ids must be unique") raise ValueError("accelerator_ids must be unique")
object.__setattr__(self, "accelerator_ids", ids) object.__setattr__(self, "accelerator_ids", ids)
@@ -379,7 +554,9 @@ class ResourcePool:
this local state. this local state.
""" """
def __init__(self, inventory: ResourceInventory, *, max_concurrency: int = 1) -> None: def __init__(
self, inventory: ResourceInventory, *, max_concurrency: int = 1
) -> None:
if not isinstance(inventory, ResourceInventory): if not isinstance(inventory, ResourceInventory):
raise ValueError("inventory must be a ResourceInventory") raise ValueError("inventory must be a ResourceInventory")
self.inventory = inventory self.inventory = inventory
@@ -396,20 +573,33 @@ class ResourcePool:
return True return True
return left.partition_id == right.partition_id return left.partition_id == right.partition_id
def reserve(self, owner_id: str, requirements: ResourceRequirements) -> ResourceAllocation: def reserve(
self, owner_id: str, requirements: ResourceRequirements
) -> ResourceAllocation:
if not isinstance(requirements, ResourceRequirements): if not isinstance(requirements, ResourceRequirements):
raise ValueError("requirements must be ResourceRequirements") raise ValueError("requirements must be ResourceRequirements")
owner_id = require_string(owner_id, "reservation owner_id", max_length=256) owner_id = require_string(owner_id, "reservation owner_id", max_length=256)
if requirements.accelerator_mode is AcceleratorMode.FRACTIONAL: if requirements.accelerator_mode is AcceleratorMode.FRACTIONAL:
raise ResourceUnavailableError("fractional-accelerator-unsupported") raise ResourceUnavailableError("fractional-accelerator-unsupported")
with self._lock: with self._lock:
if any(allocation.owner_id == owner_id for allocation in self._allocations.values()): if any(
raise ValueError("reservation owner already has an active resource allocation") allocation.owner_id == owner_id
for allocation in self._allocations.values()
):
raise ValueError(
"reservation owner already has an active resource allocation"
)
if len(self._allocations) >= self.max_concurrency: if len(self._allocations) >= self.max_concurrency:
raise ResourceUnavailableError("execution-slot-unavailable") raise ResourceUnavailableError("execution-slot-unavailable")
used_cpu = sum(allocation.cpu_cores for allocation in self._allocations.values()) used_cpu = sum(
used_memory = sum(allocation.memory_mb for allocation in self._allocations.values()) allocation.cpu_cores for allocation in self._allocations.values()
used_scratch = sum(allocation.scratch_mb for allocation in self._allocations.values()) )
used_memory = sum(
allocation.memory_mb for allocation in self._allocations.values()
)
used_scratch = sum(
allocation.scratch_mb for allocation in self._allocations.values()
)
if used_cpu + requirements.cpu_cores > self.inventory.cpu_cores: if used_cpu + requirements.cpu_cores > self.inventory.cpu_cores:
raise ResourceUnavailableError("insufficient-cpu") raise ResourceUnavailableError("insufficient-cpu")
if used_memory + requirements.memory_mb > self.inventory.memory_mb: if used_memory + requirements.memory_mb > self.inventory.memory_mb:
@@ -419,7 +609,13 @@ class ResourcePool:
static_errors = tuple( static_errors = tuple(
error error
for error in requirements.eligibility_errors(self.inventory) for error in requirements.eligibility_errors(self.inventory)
if error not in {"insufficient-cpu", "insufficient-memory", "insufficient-scratch", "accelerator-unavailable"} if error
not in {
"insufficient-cpu",
"insufficient-memory",
"insufficient-scratch",
"accelerator-unavailable",
}
) )
if static_errors: if static_errors:
raise ResourceUnavailableError(static_errors[0]) raise ResourceUnavailableError(static_errors[0])
@@ -431,12 +627,18 @@ class ResourcePool:
available_devices = tuple( available_devices = tuple(
device device
for device in self.inventory.accelerators for device in self.inventory.accelerators
if not any(self._devices_conflict(device, reserved) for reserved in reserved_devices) if not any(
self._devices_conflict(device, reserved)
for reserved in reserved_devices
)
) )
devices = requirements._matching_devices(available_devices) devices = requirements._matching_devices(available_devices)
if len(devices) < requirements.accelerator_count: if len(devices) < requirements.accelerator_count:
raise ResourceUnavailableError("accelerator-unavailable") raise ResourceUnavailableError("accelerator-unavailable")
selected = tuple(device.allocation_id for device in devices[: requirements.accelerator_count]) selected = tuple(
device.allocation_id
for device in devices[: requirements.accelerator_count]
)
allocation = ResourceAllocation( allocation = ResourceAllocation(
allocation_id=str(uuid4()), allocation_id=str(uuid4()),
owner_id=owner_id, owner_id=owner_id,
@@ -460,4 +662,6 @@ class ResourcePool:
def active_allocations(self) -> tuple[ResourceAllocation, ...]: def active_allocations(self) -> tuple[ResourceAllocation, ...]:
with self._lock: with self._lock:
return tuple(sorted(self._allocations.values(), key=lambda item: item.owner_id)) return tuple(
sorted(self._allocations.values(), key=lambda item: item.owner_id)
)
+75 -20
View File
@@ -6,7 +6,12 @@ from dataclasses import dataclass
from types import MappingProxyType from types import MappingProxyType
from typing import Mapping from typing import Mapping
from ._validation import require_identifier, require_string, validate_version_range, version_in_range from ._validation import (
require_identifier,
require_string,
validate_version_range,
version_in_range,
)
from .identity import SDK_API_VERSION from .identity import SDK_API_VERSION
from .execution import NetworkPolicy, ProcessModel from .execution import NetworkPolicy, ProcessModel
from .manifest import TrustMode, WorkloadManifest from .manifest import TrustMode, WorkloadManifest
@@ -15,6 +20,8 @@ from .workflow import StageKind
class CompatibilityError(ValueError): class CompatibilityError(ValueError):
"""A fail-closed negotiation failure with a stable machine-readable code."""
def __init__(self, code: str, message: str) -> None: def __init__(self, code: str, message: str) -> None:
self.code = require_identifier(code, "compatibility error code") self.code = require_identifier(code, "compatibility error code")
super().__init__(message) super().__init__(message)
@@ -22,6 +29,9 @@ class CompatibilityError(ValueError):
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class RuntimeCapabilities: class RuntimeCapabilities:
"""What a runtime advertises: SDK/protocol versions, profiles, features,
workload capabilities, inventory, and enforceable trust modes."""
sdk_api_version: str sdk_api_version: str
protocol_version: str protocol_version: str
profiles: tuple[str, ...] profiles: tuple[str, ...]
@@ -31,13 +41,23 @@ class RuntimeCapabilities:
trust_modes: tuple[TrustMode, ...] = (TrustMode.TRUSTED,) trust_modes: tuple[TrustMode, ...] = (TrustMode.TRUSTED,)
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__(self, "sdk_api_version", require_string(self.sdk_api_version, "sdk_api_version")) object.__setattr__(
object.__setattr__(self, "protocol_version", require_string(self.protocol_version, "protocol_version")) self,
"sdk_api_version",
require_string(self.sdk_api_version, "sdk_api_version"),
)
object.__setattr__(
self,
"protocol_version",
require_string(self.protocol_version, "protocol_version"),
)
# Parsing as an equality range provides the same numeric release rules # Parsing as an equality range provides the same numeric release rules
# used by manifest ranges without accepting an implicit/latest value. # used by manifest ranges without accepting an implicit/latest value.
validate_version_range(f"=={self.sdk_api_version}", "sdk_api_version") validate_version_range(f"=={self.sdk_api_version}", "sdk_api_version")
validate_version_range(f"=={self.protocol_version}", "protocol_version") validate_version_range(f"=={self.protocol_version}", "protocol_version")
profiles = tuple(require_identifier(value, "runtime profile") for value in self.profiles) profiles = tuple(
require_identifier(value, "runtime profile") for value in self.profiles
)
if len(profiles) != len(set(profiles)): if len(profiles) != len(set(profiles)):
raise ValueError("runtime profiles must be unique") raise ValueError("runtime profiles must be unique")
object.__setattr__(self, "profiles", profiles) object.__setattr__(self, "profiles", profiles)
@@ -50,7 +70,10 @@ class RuntimeCapabilities:
validate_version_range(f"=={text}", "runtime feature version") validate_version_range(f"=={text}", "runtime feature version")
features[canonical] = text features[canonical] = text
object.__setattr__(self, "features", MappingProxyType(features)) object.__setattr__(self, "features", MappingProxyType(features))
capabilities = tuple(require_identifier(value, "workload capability") for value in self.workload_capabilities) capabilities = tuple(
require_identifier(value, "workload capability")
for value in self.workload_capabilities
)
if len(capabilities) != len(set(capabilities)): if len(capabilities) != len(set(capabilities)):
raise ValueError("workload_capabilities must be unique") raise ValueError("workload_capabilities must be unique")
object.__setattr__(self, "workload_capabilities", capabilities) object.__setattr__(self, "workload_capabilities", capabilities)
@@ -59,7 +82,9 @@ class RuntimeCapabilities:
try: try:
trust_modes = tuple(TrustMode(value) for value in self.trust_modes) trust_modes = tuple(TrustMode(value) for value in self.trust_modes)
except (TypeError, ValueError) as error: except (TypeError, ValueError) as error:
raise ValueError("runtime trust_modes contain an unsupported value") from error raise ValueError(
"runtime trust_modes contain an unsupported value"
) from error
if not trust_modes or len(trust_modes) != len(set(trust_modes)): if not trust_modes or len(trust_modes) != len(set(trust_modes)):
raise ValueError("runtime trust_modes must be non-empty and unique") raise ValueError("runtime trust_modes must be non-empty and unique")
object.__setattr__(self, "trust_modes", trust_modes) object.__setattr__(self, "trust_modes", trust_modes)
@@ -67,6 +92,9 @@ class RuntimeCapabilities:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class NegotiatedWorkload: class NegotiatedWorkload:
"""The result of successful negotiation: selected features, fallbacks,
and the exact manifest a plan must pin."""
manifest: WorkloadManifest manifest: WorkloadManifest
optional_fallbacks: Mapping[str, str] optional_fallbacks: Mapping[str, str]
sdk_api_version: str sdk_api_version: str
@@ -76,19 +104,29 @@ class NegotiatedWorkload:
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not isinstance(self.manifest, WorkloadManifest): if not isinstance(self.manifest, WorkloadManifest):
raise ValueError("negotiated manifest must be a WorkloadManifest") raise ValueError("negotiated manifest must be a WorkloadManifest")
object.__setattr__(self, "optional_fallbacks", MappingProxyType(dict(self.optional_fallbacks))) object.__setattr__(
self, "optional_fallbacks", MappingProxyType(dict(self.optional_fallbacks))
)
object.__setattr__( object.__setattr__(
self, self,
"sdk_api_version", "sdk_api_version",
require_string(self.sdk_api_version, "negotiated sdk_api_version", max_length=32), require_string(
self.sdk_api_version, "negotiated sdk_api_version", max_length=32
),
) )
object.__setattr__( object.__setattr__(
self, self,
"protocol_version", "protocol_version",
require_string(self.protocol_version, "negotiated protocol_version", max_length=32), require_string(
self.protocol_version, "negotiated protocol_version", max_length=32
),
)
validate_version_range(
f"=={self.sdk_api_version}", "negotiated sdk_api_version"
)
validate_version_range(
f"=={self.protocol_version}", "negotiated protocol_version"
) )
validate_version_range(f"=={self.sdk_api_version}", "negotiated sdk_api_version")
validate_version_range(f"=={self.protocol_version}", "negotiated protocol_version")
selected: dict[str, str] = {} selected: dict[str, str] = {}
for name, version in self.selected_features.items(): for name, version in self.selected_features.items():
selected[require_identifier(name, "negotiated feature")] = require_string( selected[require_identifier(name, "negotiated feature")] = require_string(
@@ -108,22 +146,33 @@ def negotiate_manifest(
runtime: RuntimeCapabilities, runtime: RuntimeCapabilities,
) -> NegotiatedWorkload: ) -> NegotiatedWorkload:
"""Resolve compatibility before any package handler or planner is invoked.""" """Resolve compatibility before any package handler or planner is invoked."""
if not isinstance(manifest, WorkloadManifest) or not isinstance(runtime, RuntimeCapabilities): if not isinstance(manifest, WorkloadManifest) or not isinstance(
raise ValueError("negotiation requires WorkloadManifest and RuntimeCapabilities") runtime, RuntimeCapabilities
):
raise ValueError(
"negotiation requires WorkloadManifest and RuntimeCapabilities"
)
if runtime.sdk_api_version != SDK_API_VERSION: if runtime.sdk_api_version != SDK_API_VERSION:
raise CompatibilityError( raise CompatibilityError(
"runtime-sdk-mismatch", "runtime-sdk-mismatch",
"runtime SDK declaration does not match this SDK implementation", "runtime SDK declaration does not match this SDK implementation",
) )
if not manifest.sdk_api.contains(runtime.sdk_api_version): if not manifest.sdk_api.contains(runtime.sdk_api_version):
raise CompatibilityError("sdk-api-mismatch", "runtime SDK API is outside the manifest range") raise CompatibilityError(
"sdk-api-mismatch", "runtime SDK API is outside the manifest range"
)
if not manifest.protocol.contains(runtime.protocol_version): if not manifest.protocol.contains(runtime.protocol_version):
raise CompatibilityError("protocol-mismatch", "runtime protocol is outside the manifest range") raise CompatibilityError(
missing_profiles = sorted(set(manifest.conformance_profiles) - set(runtime.profiles)) "protocol-mismatch", "runtime protocol is outside the manifest range"
)
missing_profiles = sorted(
set(manifest.conformance_profiles) - set(runtime.profiles)
)
if missing_profiles: if missing_profiles:
raise CompatibilityError( raise CompatibilityError(
"profile-unavailable", "profile-unavailable",
"runtime does not support required profiles: " + ", ".join(missing_profiles), "runtime does not support required profiles: "
+ ", ".join(missing_profiles),
) )
if manifest.workload.name not in runtime.workload_capabilities: if manifest.workload.name not in runtime.workload_capabilities:
raise CompatibilityError( raise CompatibilityError(
@@ -131,7 +180,9 @@ def negotiate_manifest(
"runtime does not advertise the canonical workload capability", "runtime does not advertise the canonical workload capability",
) )
if manifest.environment.digest not in runtime.inventory.environment_digests: if manifest.environment.digest not in runtime.inventory.environment_digests:
raise CompatibilityError("environment-unavailable", "pinned workload environment is unavailable") raise CompatibilityError(
"environment-unavailable", "pinned workload environment is unavailable"
)
for feature in manifest.required_features: for feature in manifest.required_features:
version = runtime.features.get(feature.name) version = runtime.features.get(feature.name)
if version is None or not feature.versions.contains(version): if version is None or not feature.versions.contains(version):
@@ -167,7 +218,9 @@ def negotiate_manifest(
def require_declared(condition: bool, feature: str, message: str) -> None: def require_declared(condition: bool, feature: str, message: str) -> None:
if condition and feature not in declared_required: if condition and feature not in declared_required:
raise CompatibilityError("feature-undeclared", message + f" requires {feature}") raise CompatibilityError(
"feature-undeclared", message + f" requires {feature}"
)
for stage in manifest.workflow.stages: for stage in manifest.workflow.stages:
shape_feature = required_by_shape.get(stage.kind) shape_feature = required_by_shape.get(stage.kind)
@@ -177,7 +230,9 @@ def negotiate_manifest(
f"stage {stage.stage_id} requires declared feature {shape_feature}", f"stage {stage.stage_id} requires declared feature {shape_feature}",
) )
if stage.gang is not None and "gang-leases" not in declared_required: if stage.gang is not None and "gang-leases" not in declared_required:
raise CompatibilityError("feature-undeclared", "gang execution requires gang-leases") raise CompatibilityError(
"feature-undeclared", "gang execution requires gang-leases"
)
execution = stage.execution execution = stage.execution
require_declared( require_declared(
execution.process_model is ProcessModel.PROCESS_POOL, execution.process_model is ProcessModel.PROCESS_POOL,
+50 -11
View File
@@ -91,7 +91,10 @@ def _json_key(value: object, depth: int = 0) -> object:
if isinstance(value, bool): if isinstance(value, bool):
return ("boolean", value) return ("boolean", value)
if isinstance(value, (int, float)): if isinstance(value, (int, float)):
return ("number", Fraction(value) if isinstance(value, int) else Fraction.from_float(value)) return (
"number",
Fraction(value) if isinstance(value, int) else Fraction.from_float(value),
)
if isinstance(value, str): if isinstance(value, str):
return ("string", value) return ("string", value)
if isinstance(value, Mapping): if isinstance(value, Mapping):
@@ -148,7 +151,14 @@ def _is_json_multiple(value: int | float, divisor: int | float) -> bool:
return (value_fraction / divisor_fraction).denominator == 1 return (value_fraction / divisor_fraction).denominator == 1
def validate_schema_definition(schema: Mapping[str, object], *, _depth: int = 0) -> None: def validate_schema_definition(
schema: Mapping[str, object], *, _depth: int = 0
) -> None:
"""Validate a parameter schema against the bounded JSON Schema subset.
Raises ``ValueError`` on unknown keywords, unsupported types, unsafe
patterns, or malformed bounds.
"""
if _depth > 64: if _depth > 64:
raise _schema_error("nesting exceeds 64 levels") raise _schema_error("nesting exceeds 64 levels")
if not isinstance(schema, Mapping): if not isinstance(schema, Mapping):
@@ -167,7 +177,9 @@ def validate_schema_definition(schema: Mapping[str, object], *, _depth: int = 0)
raise _schema_error("type alternatives must be unique") raise _schema_error("type alternatives must be unique")
properties = schema.get("properties") properties = schema.get("properties")
if properties is not None: if properties is not None:
if not isinstance(properties, Mapping) or any(not isinstance(name, str) for name in properties): if not isinstance(properties, Mapping) or any(
not isinstance(name, str) for name in properties
):
raise _schema_error("properties must be an object") raise _schema_error("properties must be an object")
for child in properties.values(): for child in properties.values():
validate_schema_definition(child, _depth=_depth + 1) # type: ignore[arg-type] validate_schema_definition(child, _depth=_depth + 1) # type: ignore[arg-type]
@@ -178,7 +190,9 @@ def validate_schema_definition(schema: Mapping[str, object], *, _depth: int = 0)
validate_schema_definition(additional, _depth=_depth + 1) validate_schema_definition(additional, _depth=_depth + 1)
required = schema.get("required") required = schema.get("required")
if required is not None: if required is not None:
if not isinstance(required, (list, tuple)) or any(not isinstance(name, str) for name in required): if not isinstance(required, (list, tuple)) or any(
not isinstance(name, str) for name in required
):
raise _schema_error("required must be an array of strings") raise _schema_error("required must be an array of strings")
if len(required) != len(set(required)): if len(required) != len(set(required)):
raise _schema_error("required names must be unique") raise _schema_error("required names must be unique")
@@ -205,20 +219,35 @@ def validate_schema_definition(schema: Mapping[str, object], *, _depth: int = 0)
raise _schema_error("enum values must be unique") raise _schema_error("enum values must be unique")
seen_enum.add(key) seen_enum.add(key)
for keyword in ( for keyword in (
"minProperties", "maxProperties", "minItems", "maxItems", "minLength", "maxLength" "minProperties",
"maxProperties",
"minItems",
"maxItems",
"minLength",
"maxLength",
): ):
value = schema.get(keyword) value = schema.get(keyword)
if value is not None and (isinstance(value, bool) or not isinstance(value, int) or value < 0): if value is not None and (
isinstance(value, bool) or not isinstance(value, int) or value < 0
):
raise _schema_error(f"{keyword} must be a non-negative integer") raise _schema_error(f"{keyword} must be a non-negative integer")
for minimum, maximum in ( for minimum, maximum in (
("minProperties", "maxProperties"), ("minProperties", "maxProperties"),
("minItems", "maxItems"), ("minItems", "maxItems"),
("minLength", "maxLength"), ("minLength", "maxLength"),
): ):
if minimum in schema and maximum in schema and schema[minimum] > schema[maximum]: # type: ignore[operator] if (
minimum in schema
and maximum in schema
and schema[minimum] > schema[maximum] # type: ignore[operator]
):
raise _schema_error(f"{minimum} must not exceed {maximum}") raise _schema_error(f"{minimum} must not exceed {maximum}")
for keyword in ( for keyword in (
"minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf" "minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"multipleOf",
): ):
value = schema.get(keyword) value = schema.get(keyword)
if value is not None and ( if value is not None and (
@@ -262,7 +291,9 @@ def _type_matches(value: object, expected: str) -> bool:
def _failure(path: str, reason: str) -> ParameterValidationError: def _failure(path: str, reason: str) -> ParameterValidationError:
return ParameterValidationError(f"job parameters violate their schema at {path}: {reason}") return ParameterValidationError(
f"job parameters violate their schema at {path}: {reason}"
)
def validate_parameter_instance( def validate_parameter_instance(
@@ -272,6 +303,11 @@ def validate_parameter_instance(
path: str = "$", path: str = "$",
_depth: int = 0, _depth: int = 0,
) -> None: ) -> None:
"""Validate one parameter value against a schema subset node.
Raises ``ParameterValidationError`` (a ``ValueError``) with a sanitized
JSON path on the first violation.
"""
if _depth > 64: if _depth > 64:
raise _failure(path, "nesting exceeds 64 levels") raise _failure(path, "nesting exceeds 64 levels")
raw_type = schema.get("type") raw_type = schema.get("type")
@@ -280,7 +316,8 @@ def validate_parameter_instance(
if not any(_type_matches(value, item) for item in expected): if not any(_type_matches(value, item) for item in expected):
raise _failure(path, "type mismatch") raise _failure(path, "type mismatch")
if "enum" in schema and not any( if "enum" in schema and not any(
_json_equal(value, candidate) for candidate in schema["enum"] # type: ignore[union-attr] _json_equal(value, candidate)
for candidate in schema["enum"] # type: ignore[union-attr]
): ):
raise _failure(path, "value is outside enum") raise _failure(path, "value is outside enum")
if "const" in schema and not _json_equal(value, schema["const"]): if "const" in schema and not _json_equal(value, schema["const"]):
@@ -334,7 +371,9 @@ def validate_parameter_instance(
elif additional is False: elif additional is False:
raise _failure(path, f"unknown field {name}") raise _failure(path, f"unknown field {name}")
elif isinstance(additional, Mapping): elif isinstance(additional, Mapping):
validate_parameter_instance(child, additional, path=f"{path}.{name}", _depth=_depth + 1) validate_parameter_instance(
child, additional, path=f"{path}.{name}", _depth=_depth + 1
)
if isinstance(value, (list, tuple)): if isinstance(value, (list, tuple)):
minimum = schema.get("minItems") minimum = schema.get("minItems")
maximum = schema.get("maxItems") maximum = schema.get("maxItems")
+284 -92
View File
@@ -12,7 +12,7 @@ from decimal import Decimal
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from types import MappingProxyType from types import MappingProxyType
from typing import Any, Callable, Iterable, Mapping, Sequence from typing import Any, Callable, Iterable, Mapping, Sequence, cast
from ._validation import ( from ._validation import (
enum_value, enum_value,
@@ -34,6 +34,12 @@ from .manifest import TrustMode
class VerificationStatus(str, Enum): class VerificationStatus(str, Enum):
"""The outcome of verification.
Only ``ACCEPTED`` satisfies a stage; ``INCONCLUSIVE`` must never be
treated as success by a reducer default.
"""
ACCEPTED = "accepted" ACCEPTED = "accepted"
REJECTED = "rejected" REJECTED = "rejected"
INCONCLUSIVE = "inconclusive" INCONCLUSIVE = "inconclusive"
@@ -41,6 +47,12 @@ class VerificationStatus(str, Enum):
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class VerificationDecision: class VerificationDecision:
"""An immutable verifier outcome with bounded sanitized evidence.
``accepted_digest`` is set only for accepted decisions; evidence is
limited to 16 KiB and rejects local paths and transport URLs.
"""
status: VerificationStatus status: VerificationStatus
verifier: ComponentRef verifier: ComponentRef
reason_code: str reason_code: str
@@ -48,12 +60,29 @@ class VerificationDecision:
accepted_digest: str | None = None accepted_digest: str | None = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__(self, "status", enum_value(VerificationStatus, self.status, "verification.status")) object.__setattr__(
self,
"status",
enum_value(VerificationStatus, self.status, "verification.status"),
)
if not isinstance(self.verifier, ComponentRef): if not isinstance(self.verifier, ComponentRef):
raise ValueError("verification verifier must be a ComponentRef") raise ValueError("verification verifier must be a ComponentRef")
object.__setattr__(self, "reason_code", require_identifier(self.reason_code, "verification.reason_code")) object.__setattr__(
evidence = freeze_json_mapping(self.evidence, "verification.evidence", forbid_locations=True) self,
if len(json.dumps(thaw_json(evidence), sort_keys=True, allow_nan=False).encode("utf-8")) > 16_384: "reason_code",
require_identifier(self.reason_code, "verification.reason_code"),
)
evidence = freeze_json_mapping(
self.evidence, "verification.evidence", forbid_locations=True
)
if (
len(
json.dumps(thaw_json(evidence), sort_keys=True, allow_nan=False).encode(
"utf-8"
)
)
> 16_384
):
raise ValueError("verification evidence exceeds 16 KiB") raise ValueError("verification evidence exceeds 16 KiB")
object.__setattr__(self, "evidence", evidence) object.__setattr__(self, "evidence", evidence)
if self.accepted_digest is not None: if self.accepted_digest is not None:
@@ -64,7 +93,10 @@ class VerificationDecision:
) )
if self.status is VerificationStatus.ACCEPTED and self.accepted_digest is None: if self.status is VerificationStatus.ACCEPTED and self.accepted_digest is None:
raise ValueError("accepted verification requires an accepted_digest") raise ValueError("accepted verification requires an accepted_digest")
if self.status is not VerificationStatus.ACCEPTED and self.accepted_digest is not None: if (
self.status is not VerificationStatus.ACCEPTED
and self.accepted_digest is not None
):
raise ValueError("only accepted verification may carry an accepted_digest") raise ValueError("only accepted verification may carry an accepted_digest")
def to_dict(self) -> dict[str, object]: def to_dict(self) -> dict[str, object]:
@@ -126,7 +158,9 @@ class VerificationBinding:
object.__setattr__( object.__setattr__(
self, self,
"package_digest", "package_digest",
require_sha256(self.package_digest, "verification package_digest", prefixed=True), require_sha256(
self.package_digest, "verification package_digest", prefixed=True
),
) )
object.__setattr__( object.__setattr__(
self, self,
@@ -173,7 +207,9 @@ class VerificationBinding:
) )
for name, version in selected_features.items(): for name, version in selected_features.items():
require_identifier(name, "verification selected feature") require_identifier(name, "verification selected feature")
require_string(version, "verification selected feature version", max_length=32) require_string(
version, "verification selected feature version", max_length=32
)
parse_release(version, "verification selected feature version") parse_release(version, "verification selected feature version")
for name, fallback in optional_fallbacks.items(): for name, fallback in optional_fallbacks.items():
require_identifier(name, "verification fallback feature") require_identifier(name, "verification fallback feature")
@@ -183,19 +219,28 @@ class VerificationBinding:
object.__setattr__(self, "selected_features", selected_features) object.__setattr__(self, "selected_features", selected_features)
object.__setattr__(self, "optional_fallbacks", optional_fallbacks) object.__setattr__(self, "optional_fallbacks", optional_fallbacks)
from ._validation import require_uuid from ._validation import require_uuid
object.__setattr__(self, "job_id", require_uuid(self.job_id, "verification job_id"))
object.__setattr__(self, "task_id", require_uuid(self.task_id, "verification task_id")) object.__setattr__(
self, "job_id", require_uuid(self.job_id, "verification job_id")
)
object.__setattr__(
self, "task_id", require_uuid(self.task_id, "verification task_id")
)
if not isinstance(self.verifier, ComponentRef): if not isinstance(self.verifier, ComponentRef):
raise ValueError("verification binding verifier must be a ComponentRef") raise ValueError("verification binding verifier must be a ComponentRef")
object.__setattr__( object.__setattr__(
self, self,
"sdk_api_version", "sdk_api_version",
require_string(self.sdk_api_version, "verification sdk_api_version", max_length=32), require_string(
self.sdk_api_version, "verification sdk_api_version", max_length=32
),
) )
object.__setattr__( object.__setattr__(
self, self,
"protocol_version", "protocol_version",
require_string(self.protocol_version, "verification protocol_version", max_length=32), require_string(
self.protocol_version, "verification protocol_version", max_length=32
),
) )
parse_release(self.sdk_api_version, "verification sdk_api_version") parse_release(self.sdk_api_version, "verification sdk_api_version")
parse_release(self.protocol_version, "verification protocol_version") parse_release(self.protocol_version, "verification protocol_version")
@@ -217,11 +262,15 @@ class VerificationBinding:
) )
schemas = tuple(self.artifact_schemas) schemas = tuple(self.artifact_schemas)
if not schemas or any(not isinstance(schema, SchemaRef) for schema in schemas): if not schemas or any(not isinstance(schema, SchemaRef) for schema in schemas):
raise ValueError("verification artifact_schemas must contain schema identities") raise ValueError(
"verification artifact_schemas must contain schema identities"
)
if len(schemas) != len(set(schemas)) or schemas != tuple( if len(schemas) != len(set(schemas)) or schemas != tuple(
sorted(schemas, key=lambda schema: schema.canonical) sorted(schemas, key=lambda schema: schema.canonical)
): ):
raise ValueError("verification artifact_schemas must be unique and canonical") raise ValueError(
"verification artifact_schemas must be unique and canonical"
)
object.__setattr__(self, "artifact_schemas", schemas) object.__setattr__(self, "artifact_schemas", schemas)
try: try:
trust_mode = TrustMode(self.trust_mode) trust_mode = TrustMode(self.trust_mode)
@@ -283,14 +332,25 @@ class VerificationBinding:
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise ValueError("verification binding must be an object") raise ValueError("verification binding must be an object")
fields = { fields = {
"workload", "task_key", "package_digest", "manifest_digest", "workload",
"environment_digest", "parameters_digest", "input_collection_digest", "task_key",
"package_digest",
"manifest_digest",
"environment_digest",
"parameters_digest",
"input_collection_digest",
"execution_contract_digest", "execution_contract_digest",
"selected_features", "optional_fallbacks", "selected_features",
"job_id", "task_id", "optional_fallbacks",
"verifier", "sdk_api_version", "protocol_version", "job_id",
"manifest_schema_version", "workflow_schema_version", "task_id",
"artifact_schemas", "trust_mode", "verifier",
"sdk_api_version",
"protocol_version",
"manifest_schema_version",
"workflow_schema_version",
"artifact_schemas",
"trust_mode",
} }
require_exact_keys(value, fields, "verification binding") require_exact_keys(value, fields, "verification binding")
schemas = value["artifact_schemas"] schemas = value["artifact_schemas"]
@@ -321,6 +381,12 @@ class VerificationBinding:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class VerifyContext: class VerifyContext:
"""Coordinator-owned verification inputs: expected outputs, byte budget,
quorum size, an optional reference, and the binding for non-trusted modes.
Multi-vote contexts automatically require distinct authenticated owners.
"""
expected_outputs: Mapping[str, PortSpec] expected_outputs: Mapping[str, PortSpec]
max_output_bytes: int max_output_bytes: int
minimum_matches: int = 1 minimum_matches: int = 1
@@ -339,8 +405,16 @@ class VerifyContext:
raise ValueError("expected_outputs values must be PortSpec values") raise ValueError("expected_outputs values must be PortSpec values")
ports[canonical] = port ports[canonical] = port
object.__setattr__(self, "expected_outputs", MappingProxyType(ports)) object.__setattr__(self, "expected_outputs", MappingProxyType(ports))
object.__setattr__(self, "max_output_bytes", require_positive_int(self.max_output_bytes, "max_output_bytes")) object.__setattr__(
object.__setattr__(self, "minimum_matches", require_positive_int(self.minimum_matches, "minimum_matches")) self,
"max_output_bytes",
require_positive_int(self.max_output_bytes, "max_output_bytes"),
)
object.__setattr__(
self,
"minimum_matches",
require_positive_int(self.minimum_matches, "minimum_matches"),
)
if not isinstance(self.require_distinct_owners, bool): if not isinstance(self.require_distinct_owners, bool):
raise ValueError("require_distinct_owners must be a boolean") raise ValueError("require_distinct_owners must be a boolean")
# A multi-vote quorum is never allowed to fall back to anonymous # A multi-vote quorum is never allowed to fall back to anonymous
@@ -348,7 +422,9 @@ class VerifyContext:
# convenient, while every quorum must carry coordinator-owned owners. # convenient, while every quorum must carry coordinator-owned owners.
if self.minimum_matches > 1: if self.minimum_matches > 1:
object.__setattr__(self, "require_distinct_owners", True) object.__setattr__(self, "require_distinct_owners", True)
if self.binding is not None and not isinstance(self.binding, VerificationBinding): if self.binding is not None and not isinstance(
self.binding, VerificationBinding
):
raise ValueError("binding must be a VerificationBinding") raise ValueError("binding must be a VerificationBinding")
try: try:
trust_mode = TrustMode(self.trust_mode) trust_mode = TrustMode(self.trust_mode)
@@ -356,21 +432,29 @@ class VerifyContext:
raise ValueError("verification trust_mode is unsupported") from error raise ValueError("verification trust_mode is unsupported") from error
object.__setattr__(self, "trust_mode", trust_mode) object.__setattr__(self, "trust_mode", trust_mode)
if self.binding is not None and self.binding.trust_mode is not trust_mode: if self.binding is not None and self.binding.trust_mode is not trust_mode:
raise ValueError("verification context trust mode does not match its binding") raise ValueError(
"verification context trust mode does not match its binding"
)
if trust_mode is not TrustMode.TRUSTED and self.binding is None: if trust_mode is not TrustMode.TRUSTED and self.binding is None:
raise ValueError("non-trusted verification requires a coordinator binding") raise ValueError("non-trusted verification requires a coordinator binding")
if trust_mode is TrustMode.UNTRUSTED_QUORUM: if trust_mode is TrustMode.UNTRUSTED_QUORUM:
if self.minimum_matches < 2: if self.minimum_matches < 2:
raise ValueError("untrusted quorum requires at least two matching owners") raise ValueError(
"untrusted quorum requires at least two matching owners"
)
object.__setattr__(self, "require_distinct_owners", True) object.__setattr__(self, "require_distinct_owners", True)
if self.require_distinct_owners and self.binding is None: if self.require_distinct_owners and self.binding is None:
raise ValueError("multi-owner verification requires a coordinator binding") raise ValueError("multi-owner verification requires a coordinator binding")
if self.reference is not None: if self.reference is not None:
if not isinstance(self.reference, OutputManifest): if not isinstance(self.reference, OutputManifest):
raise ValueError("reference must be an OutputManifest") raise ValueError("reference must be an OutputManifest")
self.reference.validate_against(self.expected_outputs, max_output_bytes=self.max_output_bytes) self.reference.validate_against(
self.expected_outputs, max_output_bytes=self.max_output_bytes
)
if self.binding is not None and not self.binding.matches(self.reference): if self.binding is not None and not self.binding.matches(self.reference):
raise ValueError("reference output does not match the coordinator binding") raise ValueError(
"reference output does not match the coordinator binding"
)
_CANDIDATE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$") _CANDIDATE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
@@ -432,9 +516,15 @@ class CandidateOutput:
).encode("utf-8") ).encode("utf-8")
def authenticated_by(self, key: bytes) -> bool: def authenticated_by(self, key: bytes) -> bool:
if self.authentication_tag is None or not isinstance(key, bytes) or len(key) < 32: if (
self.authentication_tag is None
or not isinstance(key, bytes)
or len(key) < 32
):
return False return False
expected = hmac.new(key, self._authentication_payload(), hashlib.sha256).hexdigest() expected = hmac.new(
key, self._authentication_payload(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(self.authentication_tag, expected) return hmac.compare_digest(self.authentication_tag, expected)
@classmethod @classmethod
@@ -451,7 +541,9 @@ class CandidateOutput:
cannot confer this process-local authority marker. cannot confer this process-local authority marker.
""" """
if not isinstance(authentication_key, bytes) or len(authentication_key) < 32: if not isinstance(authentication_key, bytes) or len(authentication_key) < 32:
raise ValueError("candidate authentication key must contain at least 32 bytes") raise ValueError(
"candidate authentication key must contain at least 32 bytes"
)
unsigned = cls(candidate_id, owner_id, manifest) unsigned = cls(candidate_id, owner_id, manifest)
tag = hmac.new( tag = hmac.new(
authentication_key, authentication_key,
@@ -531,7 +623,7 @@ class CandidateOutputs:
else: else:
if any(not isinstance(value, CandidateOutput) for value in values): if any(not isinstance(value, CandidateOutput) for value in values):
raise ValueError("candidates must contain CandidateOutput values") raise ValueError("candidates must contain CandidateOutput values")
normalized = values normalized = cast(tuple[CandidateOutput, ...], values)
candidate_ids = [value.candidate_id for value in normalized] candidate_ids = [value.candidate_id for value in normalized]
if len(candidate_ids) != len(set(candidate_ids)): if len(candidate_ids) != len(set(candidate_ids)):
raise ValueError("candidate_id values must be unique") raise ValueError("candidate_id values must be unique")
@@ -553,7 +645,9 @@ class CandidateOutputs:
candidates = value["candidates"] candidates = value["candidates"]
if not isinstance(candidates, list): if not isinstance(candidates, list):
raise ValueError("candidate outputs candidates must be an array") raise ValueError("candidate outputs candidates must be an array")
return cls(candidates=tuple(CandidateOutput.from_dict(item) for item in candidates)) return cls(
candidates=tuple(CandidateOutput.from_dict(item) for item in candidates)
)
@classmethod @classmethod
def from_authenticated_dict( def from_authenticated_dict(
@@ -568,7 +662,9 @@ class CandidateOutputs:
""" """
decoded = cls.from_dict(value) decoded = cls.from_dict(value)
if not isinstance(authentication_key, bytes) or len(authentication_key) < 32: if not isinstance(authentication_key, bytes) or len(authentication_key) < 32:
raise ValueError("candidate authentication key must contain at least 32 bytes") raise ValueError(
"candidate authentication key must contain at least 32 bytes"
)
for candidate in decoded.candidates: for candidate in decoded.candidates:
if not candidate.authenticated_by(authentication_key): if not candidate.authenticated_by(authentication_key):
raise ValueError("candidate envelope authentication failed") raise ValueError("candidate envelope authentication failed")
@@ -588,8 +684,7 @@ def _authentication_failure(
): ):
return None return None
invalid = sum( invalid = sum(
candidate.owner_id is None candidate.owner_id is None or not candidate.coordinator_authenticated
or not candidate.coordinator_authenticated
for candidate in candidates.candidates for candidate in candidates.candidates
) )
if invalid: if invalid:
@@ -612,7 +707,9 @@ def _verify_loaded_candidates(
compare: Callable[[OutputManifest, OutputManifest], VerificationDecision], compare: Callable[[OutputManifest, OutputManifest], VerificationDecision],
) -> VerificationDecision: ) -> VerificationDecision:
"""Apply a package-owned loader/comparator without trusting vote replay.""" """Apply a package-owned loader/comparator without trusting vote replay."""
if not isinstance(context, VerifyContext) or not isinstance(candidates, CandidateOutputs): if not isinstance(context, VerifyContext) or not isinstance(
candidates, CandidateOutputs
):
raise ValueError("verifier requires VerifyContext and CandidateOutputs") raise ValueError("verifier requires VerifyContext and CandidateOutputs")
authentication_failure = _authentication_failure(context, candidates, identity) authentication_failure = _authentication_failure(context, candidates, identity)
if authentication_failure is not None: if authentication_failure is not None:
@@ -643,7 +740,9 @@ def _verify_loaded_candidates(
continue continue
seen_owners.add(candidate.owner_id) seen_owners.add(candidate.owner_id)
try: try:
if context.binding is not None and not context.binding.matches(candidate.manifest): if context.binding is not None and not context.binding.matches(
candidate.manifest
):
raise ValueError("candidate does not match the coordinator binding") raise ValueError("candidate does not match the coordinator binding")
candidate.manifest.validate_against( candidate.manifest.validate_against(
context.expected_outputs, context.expected_outputs,
@@ -686,6 +785,14 @@ def _verify_loaded_candidates(
class ExactArtifactVerifier: class ExactArtifactVerifier:
"""Whole-artifact SHA-256 acceptance for byte-exact workloads.
Compares logical port/collection/schema/content digests while ignoring
coordinator UUIDs, timestamps, and worker identity; counts at most one
vote per owner and accepts only a declared reference match or an
unambiguous quorum.
"""
identity = ComponentRef("exact-artifact", 1) identity = ComponentRef("exact-artifact", 1)
configuration: Mapping[str, object] = MappingProxyType({}) configuration: Mapping[str, object] = MappingProxyType({})
@@ -694,9 +801,15 @@ class ExactArtifactVerifier:
context: VerifyContext, context: VerifyContext,
candidates: CandidateOutputs, candidates: CandidateOutputs,
) -> VerificationDecision: ) -> VerificationDecision:
if not isinstance(context, VerifyContext) or not isinstance(candidates, CandidateOutputs): if not isinstance(context, VerifyContext) or not isinstance(
raise ValueError("exact verifier requires VerifyContext and CandidateOutputs") candidates, CandidateOutputs
authentication_failure = _authentication_failure(context, candidates, self.identity) ):
raise ValueError(
"exact verifier requires VerifyContext and CandidateOutputs"
)
authentication_failure = _authentication_failure(
context, candidates, self.identity
)
if authentication_failure is not None: if authentication_failure is not None:
return authentication_failure return authentication_failure
if context.require_distinct_owners: if context.require_distinct_owners:
@@ -718,7 +831,9 @@ class ExactArtifactVerifier:
invalid = 0 invalid = 0
for candidate in candidates.candidates: for candidate in candidates.candidates:
try: try:
if context.binding is not None and not context.binding.matches(candidate.manifest): if context.binding is not None and not context.binding.matches(
candidate.manifest
):
raise ValueError("candidate does not match the coordinator binding") raise ValueError("candidate does not match the coordinator binding")
candidate.manifest.validate_against( candidate.manifest.validate_against(
context.expected_outputs, context.expected_outputs,
@@ -735,7 +850,10 @@ class ExactArtifactVerifier:
else VerificationStatus.INCONCLUSIVE, else VerificationStatus.INCONCLUSIVE,
self.identity, self.identity,
"no-valid-candidates" if candidates.candidates else "no-candidates", "no-valid-candidates" if candidates.candidates else "no-candidates",
{"candidate_count": len(candidates.candidates), "invalid_count": invalid}, {
"candidate_count": len(candidates.candidates),
"invalid_count": invalid,
},
) )
owner_digests: dict[str, set[str]] = {} owner_digests: dict[str, set[str]] = {}
for candidate in valid: for candidate in valid:
@@ -819,12 +937,14 @@ class ExactArtifactVerifier:
VerificationStatus.ACCEPTED, VerificationStatus.ACCEPTED,
self.identity, self.identity,
"quorum-match", "quorum-match",
with_duplicate_evidence({ with_duplicate_evidence(
"matched": matches, {
"required": context.minimum_matches, "matched": matches,
"distinct_digests": len(counts), "required": context.minimum_matches,
"invalid_count": invalid, "distinct_digests": len(counts),
}), "invalid_count": invalid,
}
),
digest, digest,
) )
if tied and matches >= context.minimum_matches: if tied and matches >= context.minimum_matches:
@@ -837,12 +957,14 @@ class ExactArtifactVerifier:
status, status,
self.identity, self.identity,
reason, reason,
with_duplicate_evidence({ with_duplicate_evidence(
"largest_group": matches, {
"required": context.minimum_matches, "largest_group": matches,
"distinct_digests": len(counts), "required": context.minimum_matches,
"invalid_count": invalid, "distinct_digests": len(counts),
}), "invalid_count": invalid,
}
),
) )
@@ -865,8 +987,7 @@ def _numeric_digest_value(value: object, depth: int = 0) -> object:
if any(not isinstance(key, str) for key in value): if any(not isinstance(key, str) for key in value):
raise ValueError("numeric objects must use JSON string keys") raise ValueError("numeric objects must use JSON string keys")
return { return {
key: _numeric_digest_value(child, depth + 1) key: _numeric_digest_value(child, depth + 1) for key, child in value.items()
for key, child in value.items()
} }
if isinstance(value, (list, tuple)): if isinstance(value, (list, tuple)):
return [_numeric_digest_value(child, depth + 1) for child in value] return [_numeric_digest_value(child, depth + 1) for child in value]
@@ -911,6 +1032,9 @@ def _decimal_evidence(value: Decimal) -> int | float | str:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class NumericTolerance: class NumericTolerance:
"""Bounded numeric comparison policy: absolute/relative/ULP tolerances,
NaN policy, and a maximum element count for structured values."""
absolute: float = 0.0 absolute: float = 0.0
relative: float = 0.0 relative: float = 0.0
max_ulps: int = 0 max_ulps: int = 0
@@ -920,8 +1044,14 @@ class NumericTolerance:
def __post_init__(self) -> None: def __post_init__(self) -> None:
for field in ("absolute", "relative"): for field in ("absolute", "relative"):
value = getattr(self, field) value = getattr(self, field)
if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0: if (
raise ValueError(f"numeric tolerance {field} must be a finite non-negative number") isinstance(value, bool)
or not isinstance(value, (int, float))
or value < 0
):
raise ValueError(
f"numeric tolerance {field} must be a finite non-negative number"
)
try: try:
converted = float(value) converted = float(value)
except (OverflowError, ValueError) as error: except (OverflowError, ValueError) as error:
@@ -929,10 +1059,18 @@ class NumericTolerance:
f"numeric tolerance {field} must be a finite non-negative number" f"numeric tolerance {field} must be a finite non-negative number"
) from error ) from error
if not math.isfinite(converted): if not math.isfinite(converted):
raise ValueError(f"numeric tolerance {field} must be a finite non-negative number") raise ValueError(
f"numeric tolerance {field} must be a finite non-negative number"
)
object.__setattr__(self, field, converted) object.__setattr__(self, field, converted)
if isinstance(self.max_ulps, bool) or not isinstance(self.max_ulps, int) or self.max_ulps < 0: if (
raise ValueError("numeric tolerance max_ulps must be a non-negative integer") isinstance(self.max_ulps, bool)
or not isinstance(self.max_ulps, int)
or self.max_ulps < 0
):
raise ValueError(
"numeric tolerance max_ulps must be a non-negative integer"
)
if self.nan_policy not in {"reject", "equal"}: if self.nan_policy not in {"reject", "equal"}:
raise ValueError("numeric tolerance nan_policy must be reject or equal") raise ValueError("numeric tolerance nan_policy must be reject or equal")
object.__setattr__( object.__setattr__(
@@ -943,6 +1081,13 @@ class NumericTolerance:
class NumericToleranceVerifier: class NumericToleranceVerifier:
"""Reference-based structured numeric comparison verifier.
Requires a package-owned ``value_loader`` to turn artifacts into bounded
structured values; without one, verification returns ``inconclusive``
rather than accepting bytes it did not parse.
"""
identity = ComponentRef("numeric-tolerance", 1) identity = ComponentRef("numeric-tolerance", 1)
def __init__( def __init__(
@@ -974,8 +1119,12 @@ class NumericToleranceVerifier:
context: VerifyContext, context: VerifyContext,
candidates: CandidateOutputs, candidates: CandidateOutputs,
) -> VerificationDecision: ) -> VerificationDecision:
if not isinstance(context, VerifyContext) or not isinstance(candidates, CandidateOutputs): if not isinstance(context, VerifyContext) or not isinstance(
raise ValueError("numeric verifier requires VerifyContext and CandidateOutputs") candidates, CandidateOutputs
):
raise ValueError(
"numeric verifier requires VerifyContext and CandidateOutputs"
)
if self._value_loader is None: if self._value_loader is None:
return VerificationDecision( return VerificationDecision(
VerificationStatus.INCONCLUSIVE, VerificationStatus.INCONCLUSIVE,
@@ -984,7 +1133,9 @@ class NumericToleranceVerifier:
{"candidate_count": len(candidates.candidates)}, {"candidate_count": len(candidates.candidates)},
) )
def compare(reference: OutputManifest, candidate: OutputManifest) -> VerificationDecision: def compare(
reference: OutputManifest, candidate: OutputManifest
) -> VerificationDecision:
assert self._value_loader is not None assert self._value_loader is not None
return self.verify_values( return self.verify_values(
self._value_loader(reference), self._value_loader(reference),
@@ -1045,15 +1196,20 @@ class NumericToleranceVerifier:
difference_int = abs(actual - expected) difference_int = abs(actual - expected)
allowed_decimal = max( allowed_decimal = max(
Decimal(str(self.tolerance.absolute)), Decimal(str(self.tolerance.absolute)),
Decimal(str(self.tolerance.relative)) * Decimal(max(abs(expected), abs(actual))), Decimal(str(self.tolerance.relative))
* Decimal(max(abs(expected), abs(actual))),
) )
if Decimal(difference_int) <= allowed_decimal: if Decimal(difference_int) <= allowed_decimal:
return None return None
return "numeric-mismatch", location, { return (
"absolute_error": difference_int, "numeric-mismatch",
"allowed_error": _decimal_evidence(allowed_decimal), location,
"ulp_distance": 0, {
} "absolute_error": difference_int,
"allowed_error": _decimal_evidence(allowed_decimal),
"ulp_distance": 0,
},
)
if ( if (
isinstance(expected, int) isinstance(expected, int)
and abs(expected).bit_length() > 1024 and abs(expected).bit_length() > 1024
@@ -1064,7 +1220,11 @@ class NumericToleranceVerifier:
left = float(expected) if isinstance(expected, int) else expected left = float(expected) if isinstance(expected, int) else expected
right = float(actual) if isinstance(actual, int) else actual right = float(actual) if isinstance(actual, int) else actual
if math.isnan(left) or math.isnan(right): if math.isnan(left) or math.isnan(right):
if self.tolerance.nan_policy == "equal" and math.isnan(left) and math.isnan(right): if (
self.tolerance.nan_policy == "equal"
and math.isnan(left)
and math.isnan(right)
):
return None return None
return "nan-policy", location, {} return "nan-policy", location, {}
if not math.isfinite(left) or not math.isfinite(right): if not math.isfinite(left) or not math.isfinite(right):
@@ -1094,39 +1254,61 @@ class NumericToleranceVerifier:
ulp_distance is not None and ulp_distance <= self.tolerance.max_ulps ulp_distance is not None and ulp_distance <= self.tolerance.max_ulps
): ):
return None return None
return "numeric-mismatch", location, { return (
"absolute_error": _decimal_evidence(difference_decimal), "numeric-mismatch",
"allowed_error": _decimal_evidence(allowed_decimal), location,
"ulp_distance": ulp_distance if ulp_distance is not None else 0, {
} "absolute_error": _decimal_evidence(difference_decimal),
"allowed_error": _decimal_evidence(allowed_decimal),
"ulp_distance": ulp_distance if ulp_distance is not None else 0,
},
)
if isinstance(expected, Mapping) and isinstance(actual, Mapping): if isinstance(expected, Mapping) and isinstance(actual, Mapping):
if any(not isinstance(key, str) for key in expected) or any( if any(not isinstance(key, str) for key in expected) or any(
not isinstance(key, str) for key in actual not isinstance(key, str) for key in actual
): ):
return "type-mismatch", location, {} return "type-mismatch", location, {}
if set(expected) != set(actual): if set(expected) != set(actual):
return "shape-mismatch", location, { return (
"missing_keys": sorted(str(key) for key in set(expected) - set(actual))[:32], "shape-mismatch",
"extra_keys": sorted(str(key) for key in set(actual) - set(expected))[:32], location,
} {
"missing_keys": sorted(
str(key) for key in set(expected) - set(actual)
)[:32],
"extra_keys": sorted(
str(key) for key in set(actual) - set(expected)
)[:32],
},
)
for key in sorted(expected, key=str): for key in sorted(expected, key=str):
mismatch = self._compare(expected[key], actual[key], f"{location}.{key}", depth + 1) mismatch = self._compare(
expected[key], actual[key], f"{location}.{key}", depth + 1
)
if mismatch is not None: if mismatch is not None:
return mismatch return mismatch
return None return None
if isinstance(expected, (list, tuple)) and isinstance(actual, (list, tuple)): if isinstance(expected, (list, tuple)) and isinstance(actual, (list, tuple)):
if len(expected) != len(actual): if len(expected) != len(actual):
return "shape-mismatch", location, {"expected_length": len(expected), "actual_length": len(actual)} return (
"shape-mismatch",
location,
{"expected_length": len(expected), "actual_length": len(actual)},
)
for index, (left, right) in enumerate(zip(expected, actual)): for index, (left, right) in enumerate(zip(expected, actual)):
mismatch = self._compare(left, right, f"{location}[{index}]", depth + 1) mismatch = self._compare(left, right, f"{location}[{index}]", depth + 1)
if mismatch is not None: if mismatch is not None:
return mismatch return mismatch
return None return None
if type(expected) is not type(actual): if type(expected) is not type(actual):
return "type-mismatch", location, { return (
"expected_type": type(expected).__name__, "type-mismatch",
"actual_type": type(actual).__name__, location,
} {
"expected_type": type(expected).__name__,
"actual_type": type(actual).__name__,
},
)
return None if expected == actual else ("value-mismatch", location, {}) return None if expected == actual else ("value-mismatch", location, {})
@@ -1159,8 +1341,12 @@ class CanonicalRecordVerifier:
context: VerifyContext, context: VerifyContext,
candidates: CandidateOutputs, candidates: CandidateOutputs,
) -> VerificationDecision: ) -> VerificationDecision:
if not isinstance(context, VerifyContext) or not isinstance(candidates, CandidateOutputs): if not isinstance(context, VerifyContext) or not isinstance(
raise ValueError("canonical verifier requires VerifyContext and CandidateOutputs") candidates, CandidateOutputs
):
raise ValueError(
"canonical verifier requires VerifyContext and CandidateOutputs"
)
if self._record_loader is None: if self._record_loader is None:
return VerificationDecision( return VerificationDecision(
VerificationStatus.INCONCLUSIVE, VerificationStatus.INCONCLUSIVE,
@@ -1169,7 +1355,9 @@ class CanonicalRecordVerifier:
{"candidate_count": len(candidates.candidates)}, {"candidate_count": len(candidates.candidates)},
) )
def compare(reference: OutputManifest, candidate: OutputManifest) -> VerificationDecision: def compare(
reference: OutputManifest, candidate: OutputManifest
) -> VerificationDecision:
assert self._record_loader is not None assert self._record_loader is not None
return self.verify_records( return self.verify_records(
self._record_loader(reference), self._record_loader(reference),
@@ -1178,12 +1366,16 @@ class CanonicalRecordVerifier:
return _verify_loaded_candidates(context, candidates, self.identity, compare) return _verify_loaded_candidates(context, candidates, self.identity, compare)
def verify_records(self, expected: Iterable[object], actual: Iterable[object]) -> VerificationDecision: def verify_records(
self, expected: Iterable[object], actual: Iterable[object]
) -> VerificationDecision:
expected_digest = hashlib.sha256() expected_digest = hashlib.sha256()
actual_digest = hashlib.sha256() actual_digest = hashlib.sha256()
counts = [0, 0] counts = [0, 0]
try: try:
for index, (stream, digest) in enumerate(((expected, expected_digest), (actual, actual_digest))): for index, (stream, digest) in enumerate(
((expected, expected_digest), (actual, actual_digest))
):
for record in stream: for record in stream:
counts[index] += 1 counts[index] += 1
if counts[index] > self._max_records: if counts[index] > self._max_records:
+290 -64
View File
@@ -24,6 +24,14 @@ from .resources import ResourceRequirements
class StageKind(str, Enum): class StageKind(str, Enum):
"""The kind of a workflow stage.
``PLAN`` stages expand dynamically, ``MAP`` stages fan out, ``REDUCE``
stages fan in, and the advanced kinds (loops, streams, services, side
effects) are declared but fail negotiation unless the runtime advertises
the corresponding features.
"""
PLAN = "plan" PLAN = "plan"
MAP = "map" MAP = "map"
REDUCE = "reduce" REDUCE = "reduce"
@@ -35,6 +43,12 @@ class StageKind(str, Enum):
class WorkflowFailurePolicy(str, Enum): class WorkflowFailurePolicy(str, Enum):
"""How a workflow behaves when a stage fails.
``FAIL_FAST`` aborts on the first failure; the remaining policies require
coordinator/runtime support and are fail-closed in v1.
"""
FAIL_FAST = "fail_fast" FAIL_FAST = "fail_fast"
CONTINUE_INDEPENDENT = "continue_independent" CONTINUE_INDEPENDENT = "continue_independent"
ALLOW_PARTIAL = "allow_partial" ALLOW_PARTIAL = "allow_partial"
@@ -43,6 +57,11 @@ class WorkflowFailurePolicy(str, Enum):
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class LoopSpec: class LoopSpec:
"""Bounded loop declaration for a ``LOOP_CONTROLLER`` stage.
Declared but not executable until a runtime advertises ``bounded-loops``.
"""
state_schema: SchemaRef state_schema: SchemaRef
max_iterations: int max_iterations: int
max_wall_seconds: int max_wall_seconds: int
@@ -54,16 +73,34 @@ class LoopSpec:
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not isinstance(self.state_schema, SchemaRef): if not isinstance(self.state_schema, SchemaRef):
raise ValueError("loop state_schema must be a SchemaRef") raise ValueError("loop state_schema must be a SchemaRef")
object.__setattr__(self, "max_iterations", require_positive_int(self.max_iterations, "loop.max_iterations")) object.__setattr__(
object.__setattr__(self, "max_wall_seconds", require_positive_int(self.max_wall_seconds, "loop.max_wall_seconds")) self,
object.__setattr__(self, "body_workflow", require_identifier(self.body_workflow, "loop.body_workflow")) "max_iterations",
require_positive_int(self.max_iterations, "loop.max_iterations"),
)
object.__setattr__(
self,
"max_wall_seconds",
require_positive_int(self.max_wall_seconds, "loop.max_wall_seconds"),
)
object.__setattr__(
self,
"body_workflow",
require_identifier(self.body_workflow, "loop.body_workflow"),
)
if not isinstance(self.continue_when, ComponentRef): if not isinstance(self.continue_when, ComponentRef):
raise ValueError("loop continue_when must be a ComponentRef") raise ValueError("loop continue_when must be a ComponentRef")
object.__setattr__(self, "checkpoint_every", require_positive_int(self.checkpoint_every, "loop.checkpoint_every")) object.__setattr__(
self,
"checkpoint_every",
require_positive_int(self.checkpoint_every, "loop.checkpoint_every"),
)
if self.checkpoint_every > self.max_iterations: if self.checkpoint_every > self.max_iterations:
raise ValueError("loop checkpoint_every must not exceed max_iterations") raise ValueError("loop checkpoint_every must not exceed max_iterations")
if self.on_limit not in {"fail", "accept-best", "return-inconclusive"}: if self.on_limit not in {"fail", "accept-best", "return-inconclusive"}:
raise ValueError("loop on_limit must be fail, accept-best, or return-inconclusive") raise ValueError(
"loop on_limit must be fail, accept-best, or return-inconclusive"
)
def to_dict(self) -> dict[str, object]: def to_dict(self) -> dict[str, object]:
return { return {
@@ -81,8 +118,13 @@ class LoopSpec:
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise ValueError("loop specification must be an object") raise ValueError("loop specification must be an object")
fields = { fields = {
"state_schema", "max_iterations", "max_wall_seconds", "body_workflow", "state_schema",
"continue_when", "checkpoint_every", "on_limit", "max_iterations",
"max_wall_seconds",
"body_workflow",
"continue_when",
"checkpoint_every",
"on_limit",
} }
require_exact_keys(value, fields, "loop specification") require_exact_keys(value, fields, "loop specification")
return cls( return cls(
@@ -98,6 +140,12 @@ class LoopSpec:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class StreamSpec: class StreamSpec:
"""Bounded stream declaration for a ``STREAM`` stage.
Declared but not executable until a runtime advertises
``stream-checkpoints``.
"""
source: str source: str
partitioning: str partitioning: str
checkpoint_schema: SchemaRef checkpoint_schema: SchemaRef
@@ -108,20 +156,40 @@ class StreamSpec:
max_windows: int max_windows: int
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__(self, "source", require_identifier(self.source, "stream.source")) object.__setattr__(
object.__setattr__(self, "partitioning", require_identifier(self.partitioning, "stream.partitioning")) self, "source", require_identifier(self.source, "stream.source")
)
object.__setattr__(
self,
"partitioning",
require_identifier(self.partitioning, "stream.partitioning"),
)
if not isinstance(self.checkpoint_schema, SchemaRef): if not isinstance(self.checkpoint_schema, SchemaRef):
raise ValueError("stream checkpoint_schema must be a SchemaRef") raise ValueError("stream checkpoint_schema must be a SchemaRef")
object.__setattr__(self, "window_seconds", require_positive_int(self.window_seconds, "stream.window_seconds")) object.__setattr__(
object.__setattr__(self, "watermark_seconds", require_nonnegative_int(self.watermark_seconds, "stream.watermark_seconds")) self,
"window_seconds",
require_positive_int(self.window_seconds, "stream.window_seconds"),
)
object.__setattr__(
self,
"watermark_seconds",
require_nonnegative_int(self.watermark_seconds, "stream.watermark_seconds"),
)
object.__setattr__( object.__setattr__(
self, self,
"backpressure_limit", "backpressure_limit",
require_positive_int(self.backpressure_limit, "stream.backpressure_limit"), require_positive_int(self.backpressure_limit, "stream.backpressure_limit"),
) )
if self.delivery_guarantee not in {"at_least_once", "exactly_once"}: if self.delivery_guarantee not in {"at_least_once", "exactly_once"}:
raise ValueError("stream delivery_guarantee must be at_least_once or exactly_once") raise ValueError(
object.__setattr__(self, "max_windows", require_positive_int(self.max_windows, "stream.max_windows")) "stream delivery_guarantee must be at_least_once or exactly_once"
)
object.__setattr__(
self,
"max_windows",
require_positive_int(self.max_windows, "stream.max_windows"),
)
def to_dict(self) -> dict[str, object]: def to_dict(self) -> dict[str, object]:
return { return {
@@ -140,8 +208,14 @@ class StreamSpec:
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise ValueError("stream specification must be an object") raise ValueError("stream specification must be an object")
fields = { fields = {
"source", "partitioning", "checkpoint_schema", "window_seconds", "source",
"watermark_seconds", "backpressure_limit", "delivery_guarantee", "max_windows", "partitioning",
"checkpoint_schema",
"window_seconds",
"watermark_seconds",
"backpressure_limit",
"delivery_guarantee",
"max_windows",
} }
require_exact_keys(value, fields, "stream specification") require_exact_keys(value, fields, "stream specification")
return cls( return cls(
@@ -158,6 +232,11 @@ class StreamSpec:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class GangSpec: class GangSpec:
"""Co-scheduled replica group for one stage.
Declared but not executable until a runtime advertises ``gang-leases``.
"""
replicas: int replicas: int
per_replica_resources: ResourceRequirements per_replica_resources: ResourceRequirements
same_topology_group: bool = False same_topology_group: bool = False
@@ -165,7 +244,9 @@ class GangSpec:
failure_mode: str = "fail_all" failure_mode: str = "fail_all"
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__(self, "replicas", require_positive_int(self.replicas, "gang.replicas")) object.__setattr__(
self, "replicas", require_positive_int(self.replicas, "gang.replicas")
)
if self.replicas < 2: if self.replicas < 2:
raise ValueError("gang execution requires at least two replicas") raise ValueError("gang execution requires at least two replicas")
if not isinstance(self.per_replica_resources, ResourceRequirements): if not isinstance(self.per_replica_resources, ResourceRequirements):
@@ -173,7 +254,11 @@ class GangSpec:
if not isinstance(self.same_topology_group, bool): if not isinstance(self.same_topology_group, bool):
raise ValueError("gang same_topology_group must be a boolean") raise ValueError("gang same_topology_group must be a boolean")
if self.bandwidth_class is not None: if self.bandwidth_class is not None:
object.__setattr__(self, "bandwidth_class", require_identifier(self.bandwidth_class, "gang.bandwidth_class")) object.__setattr__(
self,
"bandwidth_class",
require_identifier(self.bandwidth_class, "gang.bandwidth_class"),
)
if self.failure_mode != "fail_all": if self.failure_mode != "fail_all":
raise ValueError("SDK v1 gang failure_mode must be fail_all") raise ValueError("SDK v1 gang failure_mode must be fail_all")
@@ -191,13 +276,18 @@ class GangSpec:
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise ValueError("gang specification must be an object") raise ValueError("gang specification must be an object")
fields = { fields = {
"replicas", "per_replica_resources", "same_topology_group", "replicas",
"bandwidth_class", "failure_mode", "per_replica_resources",
"same_topology_group",
"bandwidth_class",
"failure_mode",
} }
require_exact_keys(value, fields, "gang specification") require_exact_keys(value, fields, "gang specification")
return cls( return cls(
replicas=value["replicas"], # type: ignore[arg-type] replicas=value["replicas"], # type: ignore[arg-type]
per_replica_resources=ResourceRequirements.from_dict(value["per_replica_resources"]), per_replica_resources=ResourceRequirements.from_dict(
value["per_replica_resources"]
),
same_topology_group=value["same_topology_group"], # type: ignore[arg-type] same_topology_group=value["same_topology_group"], # type: ignore[arg-type]
bandwidth_class=value["bandwidth_class"], # type: ignore[arg-type] bandwidth_class=value["bandwidth_class"], # type: ignore[arg-type]
failure_mode=value["failure_mode"], # type: ignore[arg-type] failure_mode=value["failure_mode"], # type: ignore[arg-type]
@@ -206,6 +296,12 @@ class GangSpec:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class SideEffectSpec: class SideEffectSpec:
"""External side-effect declaration for a ``SIDE_EFFECT`` stage.
Declared but trusted-only and not executable until a runtime advertises
``side-effect``; requires an idempotency key projected into the stage.
"""
target: str target: str
idempotency_key_parameter: str idempotency_key_parameter: str
credential_scope: str credential_scope: str
@@ -213,14 +309,26 @@ class SideEffectSpec:
manual_approval: bool = True manual_approval: bool = True
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__(self, "target", require_identifier(self.target, "side_effect.target")) object.__setattr__(
self, "target", require_identifier(self.target, "side_effect.target")
)
object.__setattr__( object.__setattr__(
self, self,
"idempotency_key_parameter", "idempotency_key_parameter",
require_identifier(self.idempotency_key_parameter, "side_effect.idempotency_key_parameter"), require_identifier(
self.idempotency_key_parameter, "side_effect.idempotency_key_parameter"
),
)
object.__setattr__(
self,
"credential_scope",
require_identifier(self.credential_scope, "side_effect.credential_scope"),
)
object.__setattr__(
self,
"compensation",
require_identifier(self.compensation, "side_effect.compensation"),
) )
object.__setattr__(self, "credential_scope", require_identifier(self.credential_scope, "side_effect.credential_scope"))
object.__setattr__(self, "compensation", require_identifier(self.compensation, "side_effect.compensation"))
if not isinstance(self.manual_approval, bool): if not isinstance(self.manual_approval, bool):
raise ValueError("side_effect.manual_approval must be a boolean") raise ValueError("side_effect.manual_approval must be a boolean")
@@ -238,7 +346,11 @@ class SideEffectSpec:
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise ValueError("side-effect specification must be an object") raise ValueError("side-effect specification must be an object")
fields = { fields = {
"target", "idempotency_key_parameter", "credential_scope", "compensation", "manual_approval", "target",
"idempotency_key_parameter",
"credential_scope",
"compensation",
"manual_approval",
} }
require_exact_keys(value, fields, "side-effect specification") require_exact_keys(value, fields, "side-effect specification")
return cls(**value) # type: ignore[arg-type] return cls(**value) # type: ignore[arg-type]
@@ -252,9 +364,13 @@ class PortRef:
stage_id: str | None = None stage_id: str | None = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__(self, "port", require_identifier(self.port, "port reference")) object.__setattr__(
self, "port", require_identifier(self.port, "port reference")
)
if self.stage_id is not None: if self.stage_id is not None:
object.__setattr__(self, "stage_id", require_identifier(self.stage_id, "stage reference")) object.__setattr__(
self, "stage_id", require_identifier(self.stage_id, "stage reference")
)
def to_dict(self) -> dict[str, object]: def to_dict(self) -> dict[str, object]:
return {"stage_id": self.stage_id, "port": self.port} return {"stage_id": self.stage_id, "port": self.port}
@@ -269,6 +385,12 @@ class PortRef:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ArtifactEdge: class ArtifactEdge:
"""A typed data flow from a source port to a stage input port.
Endpoints must declare compatible schemas; every stage input receives
exactly one edge.
"""
source: PortRef source: PortRef
target: PortRef target: PortRef
@@ -286,7 +408,10 @@ class ArtifactEdge:
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise ValueError("artifact edge must be an object") raise ValueError("artifact edge must be an object")
require_exact_keys(value, {"source", "target"}, "artifact edge") require_exact_keys(value, {"source", "target"}, "artifact edge")
return cls(source=PortRef.from_dict(value["source"]), target=PortRef.from_dict(value["target"])) return cls(
source=PortRef.from_dict(value["source"]),
target=PortRef.from_dict(value["target"]),
)
def _port_mapping(value: Mapping[str, PortSpec], field: str) -> Mapping[str, PortSpec]: def _port_mapping(value: Mapping[str, PortSpec], field: str) -> Mapping[str, PortSpec]:
@@ -303,6 +428,13 @@ def _port_mapping(value: Mapping[str, PortSpec], field: str) -> Mapping[str, Por
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class StageSpec: class StageSpec:
"""One typed stage: kind, handler entry point, ports, and policy.
``entry_point`` must be an installed handler key (runner for map/verify
stages, reducer for reduce stages); resources, execution profile, retry
policy, verifier, and trust modes are validated at construction.
"""
stage_id: str stage_id: str
kind: StageKind kind: StageKind
entry_point: str entry_point: str
@@ -323,18 +455,29 @@ class StageSpec:
side_effect: SideEffectSpec | None = None side_effect: SideEffectSpec | None = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__(self, "stage_id", require_identifier(self.stage_id, "stage_id")) object.__setattr__(
self, "stage_id", require_identifier(self.stage_id, "stage_id")
)
object.__setattr__(self, "kind", enum_value(StageKind, self.kind, "stage.kind")) object.__setattr__(self, "kind", enum_value(StageKind, self.kind, "stage.kind"))
object.__setattr__(self, "entry_point", require_entry_point(self.entry_point, "stage.entry_point")) object.__setattr__(
self,
"entry_point",
require_entry_point(self.entry_point, "stage.entry_point"),
)
needs = tuple(require_identifier(value, "stage.needs") for value in self.needs) needs = tuple(require_identifier(value, "stage.needs") for value in self.needs)
if self.stage_id in needs or len(needs) != len(set(needs)): if self.stage_id in needs or len(needs) != len(set(needs)):
raise ValueError("stage.needs must contain unique other stage IDs") raise ValueError("stage.needs must contain unique other stage IDs")
object.__setattr__(self, "needs", needs) object.__setattr__(self, "needs", needs)
object.__setattr__(self, "inputs", _port_mapping(self.inputs, "stage.inputs")) object.__setattr__(self, "inputs", _port_mapping(self.inputs, "stage.inputs"))
object.__setattr__(self, "outputs", _port_mapping(self.outputs, "stage.outputs")) object.__setattr__(
self, "outputs", _port_mapping(self.outputs, "stage.outputs")
)
if not self.outputs: if not self.outputs:
raise ValueError("a stage must declare at least one output port") raise ValueError("a stage must declare at least one output port")
names = tuple(require_identifier(value, "parameter_name") for value in self.parameter_names) names = tuple(
require_identifier(value, "parameter_name")
for value in self.parameter_names
)
if len(names) != len(set(names)): if len(names) != len(set(names)):
raise ValueError("parameter_names must be unique") raise ValueError("parameter_names must be unique")
object.__setattr__(self, "parameter_names", names) object.__setattr__(self, "parameter_names", names)
@@ -347,13 +490,19 @@ class StageSpec:
raise ValueError("stage.retry must be RetryPolicy") raise ValueError("stage.retry must be RetryPolicy")
if self.verifier is not None and not isinstance(self.verifier, ComponentRef): if self.verifier is not None and not isinstance(self.verifier, ComponentRef):
raise ValueError("stage.verifier must be a ComponentRef") raise ValueError("stage.verifier must be a ComponentRef")
modes = tuple(require_identifier(value, "trust_mode") for value in self.trust_modes) modes = tuple(
require_identifier(value, "trust_mode") for value in self.trust_modes
)
if not modes or len(modes) != len(set(modes)): if not modes or len(modes) != len(set(modes)):
raise ValueError("stage.trust_modes must be non-empty and unique") raise ValueError("stage.trust_modes must be non-empty and unique")
if not set(modes).issubset({"trusted", "verified", "untrusted_quorum"}): if not set(modes).issubset({"trusted", "verified", "untrusted_quorum"}):
raise ValueError("stage.trust_modes contains an unsupported trust mode") raise ValueError("stage.trust_modes contains an unsupported trust mode")
object.__setattr__(self, "trust_modes", modes) object.__setattr__(self, "trust_modes", modes)
object.__setattr__(self, "max_fan_out", require_positive_int(self.max_fan_out, "stage.max_fan_out")) object.__setattr__(
self,
"max_fan_out",
require_positive_int(self.max_fan_out, "stage.max_fan_out"),
)
if not isinstance(self.cacheable, bool): if not isinstance(self.cacheable, bool):
raise ValueError("stage.cacheable must be a boolean") raise ValueError("stage.cacheable must be a boolean")
advanced = { advanced = {
@@ -370,8 +519,12 @@ class StageSpec:
if self.kind is kind and declaration is None: if self.kind is kind and declaration is None:
raise ValueError(f"{kind.value} stage requires its bounded declaration") raise ValueError(f"{kind.value} stage requires its bounded declaration")
if self.kind is not kind and declaration is not None: if self.kind is not kind and declaration is not None:
raise ValueError(f"{kind.value} declaration is valid only for a {kind.value} stage") raise ValueError(
if declaration is not None and not isinstance(declaration, expected_types[kind]): f"{kind.value} declaration is valid only for a {kind.value} stage"
)
if declaration is not None and not isinstance(
declaration, expected_types[kind]
):
raise ValueError(f"{kind.value} declaration has the wrong type") raise ValueError(f"{kind.value} declaration has the wrong type")
if self.gang is not None and not isinstance(self.gang, GangSpec): if self.gang is not None and not isinstance(self.gang, GangSpec):
raise ValueError("stage.gang must be a GangSpec") raise ValueError("stage.gang must be a GangSpec")
@@ -382,11 +535,16 @@ class StageSpec:
if self.kind is StageKind.SIDE_EFFECT: if self.kind is StageKind.SIDE_EFFECT:
if self.cacheable: if self.cacheable:
raise ValueError("side-effect stages cannot be cached") raise ValueError("side-effect stages cannot be cached")
if self.execution.network not in {NetworkPolicy.ALLOWLISTED_EGRESS, NetworkPolicy.TRUSTED}: if self.execution.network not in {
NetworkPolicy.ALLOWLISTED_EGRESS,
NetworkPolicy.TRUSTED,
}:
raise ValueError("side-effect stages require explicit egress") raise ValueError("side-effect stages require explicit egress")
assert self.side_effect is not None assert self.side_effect is not None
if self.side_effect.idempotency_key_parameter not in self.parameter_names: if self.side_effect.idempotency_key_parameter not in self.parameter_names:
raise ValueError("side-effect idempotency key must be projected into the stage") raise ValueError(
"side-effect idempotency key must be projected into the stage"
)
def to_dict(self) -> dict[str, object]: def to_dict(self) -> dict[str, object]:
return { return {
@@ -407,7 +565,9 @@ class StageSpec:
"loop": self.loop.to_dict() if self.loop is not None else None, "loop": self.loop.to_dict() if self.loop is not None else None,
"stream": self.stream.to_dict() if self.stream is not None else None, "stream": self.stream.to_dict() if self.stream is not None else None,
"gang": self.gang.to_dict() if self.gang is not None else None, "gang": self.gang.to_dict() if self.gang is not None else None,
"side_effect": self.side_effect.to_dict() if self.side_effect is not None else None, "side_effect": self.side_effect.to_dict()
if self.side_effect is not None
else None,
} }
@classmethod @classmethod
@@ -415,14 +575,31 @@ class StageSpec:
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise ValueError("stage specification must be an object") raise ValueError("stage specification must be an object")
fields = { fields = {
"stage_id", "kind", "entry_point", "needs", "inputs", "outputs", "stage_id",
"parameter_names", "resources", "execution", "retry", "verifier", "kind",
"trust_modes", "max_fan_out", "cacheable", "loop", "stream", "gang", "side_effect", "entry_point",
"needs",
"inputs",
"outputs",
"parameter_names",
"resources",
"execution",
"retry",
"verifier",
"trust_modes",
"max_fan_out",
"cacheable",
"loop",
"stream",
"gang",
"side_effect",
} }
require_exact_keys(value, fields, "stage specification") require_exact_keys(value, fields, "stage specification")
arrays = (value["needs"], value["parameter_names"], value["trust_modes"]) arrays = (value["needs"], value["parameter_names"], value["trust_modes"])
if any(not isinstance(item, list) for item in arrays): if any(not isinstance(item, list) for item in arrays):
raise ValueError("stage needs, parameter_names, and trust_modes must be arrays") raise ValueError(
"stage needs, parameter_names, and trust_modes must be arrays"
)
inputs, outputs = value["inputs"], value["outputs"] inputs, outputs = value["inputs"], value["outputs"]
if not isinstance(inputs, Mapping) or not isinstance(outputs, Mapping): if not isinstance(inputs, Mapping) or not isinstance(outputs, Mapping):
raise ValueError("stage inputs and outputs must be objects") raise ValueError("stage inputs and outputs must be objects")
@@ -437,19 +614,32 @@ class StageSpec:
resources=ResourceRequirements.from_dict(value["resources"]), resources=ResourceRequirements.from_dict(value["resources"]),
execution=ExecutionProfile.from_dict(value["execution"]), execution=ExecutionProfile.from_dict(value["execution"]),
retry=RetryPolicy.from_dict(value["retry"]), retry=RetryPolicy.from_dict(value["retry"]),
verifier=None if value["verifier"] is None else ComponentRef.from_dict(value["verifier"]), verifier=None
if value["verifier"] is None
else ComponentRef.from_dict(value["verifier"]),
trust_modes=tuple(value["trust_modes"]), # type: ignore[arg-type] trust_modes=tuple(value["trust_modes"]), # type: ignore[arg-type]
max_fan_out=value["max_fan_out"], # type: ignore[arg-type] max_fan_out=value["max_fan_out"], # type: ignore[arg-type]
cacheable=value["cacheable"], # type: ignore[arg-type] cacheable=value["cacheable"], # type: ignore[arg-type]
loop=None if value["loop"] is None else LoopSpec.from_dict(value["loop"]), loop=None if value["loop"] is None else LoopSpec.from_dict(value["loop"]),
stream=None if value["stream"] is None else StreamSpec.from_dict(value["stream"]), stream=None
if value["stream"] is None
else StreamSpec.from_dict(value["stream"]),
gang=None if value["gang"] is None else GangSpec.from_dict(value["gang"]), gang=None if value["gang"] is None else GangSpec.from_dict(value["gang"]),
side_effect=None if value["side_effect"] is None else SideEffectSpec.from_dict(value["side_effect"]), side_effect=None
if value["side_effect"] is None
else SideEffectSpec.from_dict(value["side_effect"]),
) )
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class WorkflowSpec: class WorkflowSpec:
"""A versioned acyclic workflow: inputs, stages, edges, and outputs.
Construction validates complete input bindings, matching ``needs``
declarations, edge schema compatibility, acyclicity, and output port
resolution.
"""
workflow_id: str workflow_id: str
inputs: Mapping[str, PortSpec] inputs: Mapping[str, PortSpec]
stages: tuple[StageSpec, ...] stages: tuple[StageSpec, ...]
@@ -461,9 +651,15 @@ class WorkflowSpec:
schema_version: int = WORKFLOW_SCHEMA_VERSION schema_version: int = WORKFLOW_SCHEMA_VERSION
def __post_init__(self) -> None: def __post_init__(self) -> None:
require_schema_version(self.schema_version, WORKFLOW_SCHEMA_VERSION, "workflow schema_version") require_schema_version(
object.__setattr__(self, "workflow_id", require_identifier(self.workflow_id, "workflow_id")) self.schema_version, WORKFLOW_SCHEMA_VERSION, "workflow schema_version"
object.__setattr__(self, "inputs", _port_mapping(self.inputs, "workflow.inputs")) )
object.__setattr__(
self, "workflow_id", require_identifier(self.workflow_id, "workflow_id")
)
object.__setattr__(
self, "inputs", _port_mapping(self.inputs, "workflow.inputs")
)
stages = tuple(self.stages) stages = tuple(self.stages)
if not stages or any(not isinstance(stage, StageSpec) for stage in stages): if not stages or any(not isinstance(stage, StageSpec) for stage in stages):
raise ValueError("workflow stages must contain at least one StageSpec") raise ValueError("workflow stages must contain at least one StageSpec")
@@ -489,9 +685,15 @@ class WorkflowSpec:
object.__setattr__( object.__setattr__(
self, self,
"failure_policy", "failure_policy",
enum_value(WorkflowFailurePolicy, self.failure_policy, "workflow.failure_policy"), enum_value(
WorkflowFailurePolicy, self.failure_policy, "workflow.failure_policy"
),
)
object.__setattr__(
self,
"max_tasks",
require_positive_int(self.max_tasks, "workflow.max_tasks"),
) )
object.__setattr__(self, "max_tasks", require_positive_int(self.max_tasks, "workflow.max_tasks"))
object.__setattr__( object.__setattr__(
self, self,
"max_output_bytes", "max_output_bytes",
@@ -499,12 +701,16 @@ class WorkflowSpec:
) )
self._validate_graph(stage_by_id) self._validate_graph(stage_by_id)
def _source_port(self, reference: PortRef, stages: Mapping[str, StageSpec]) -> PortSpec: def _source_port(
self, reference: PortRef, stages: Mapping[str, StageSpec]
) -> PortSpec:
if reference.stage_id is None: if reference.stage_id is None:
try: try:
return self.inputs[reference.port] return self.inputs[reference.port]
except KeyError as error: except KeyError as error:
raise ValueError(f"unknown workflow input port: {reference.port}") from error raise ValueError(
f"unknown workflow input port: {reference.port}"
) from error
try: try:
stage = stages[reference.stage_id] stage = stages[reference.stage_id]
return stage.outputs[reference.port] return stage.outputs[reference.port]
@@ -538,17 +744,23 @@ class WorkflowSpec:
and target_port.collection.value in {"ordered", "keyed", "set"} and target_port.collection.value in {"ordered", "keyed", "set"}
) )
if not same_schema or not (direct_match or map_fan_in): if not same_schema or not (direct_match or map_fan_in):
raise ValueError("artifact edge source and target port declarations are incompatible") raise ValueError(
"artifact edge source and target port declarations are incompatible"
)
if edge.source.stage_id is not None: if edge.source.stage_id is not None:
dependencies[edge.target.stage_id].add(edge.source.stage_id) dependencies[edge.target.stage_id].add(edge.source.stage_id)
for stage in stages.values(): for stage in stages.values():
missing = [name for name in stage.inputs if (stage.stage_id, name) not in incoming] missing = [
name for name in stage.inputs if (stage.stage_id, name) not in incoming
]
if missing: if missing:
raise ValueError( raise ValueError(
f"stage {stage.stage_id} has unbound inputs: {', '.join(sorted(missing))}" f"stage {stage.stage_id} has unbound inputs: {', '.join(sorted(missing))}"
) )
if dependencies[stage.stage_id] != set(stage.needs): if dependencies[stage.stage_id] != set(stage.needs):
raise ValueError(f"stage {stage.stage_id} needs do not match its artifact edges") raise ValueError(
f"stage {stage.stage_id} needs do not match its artifact edges"
)
remaining = {name: set(values) for name, values in dependencies.items()} remaining = {name: set(values) for name, values in dependencies.items()}
ready = sorted(name for name, values in remaining.items() if not values) ready = sorted(name for name, values in remaining.items() if not values)
visited: list[str] = [] visited: list[str] = []
@@ -568,10 +780,12 @@ class WorkflowSpec:
def output_ports(self) -> Mapping[str, PortSpec]: def output_ports(self) -> Mapping[str, PortSpec]:
stages = {stage.stage_id: stage for stage in self.stages} stages = {stage.stage_id: stage for stage in self.stages}
return MappingProxyType({ return MappingProxyType(
name: self._source_port(reference, stages) {
for name, reference in self.outputs.items() name: self._source_port(reference, stages)
}) for name, reference in self.outputs.items()
}
)
def to_dict(self) -> dict[str, object]: def to_dict(self) -> dict[str, object]:
return { return {
@@ -580,7 +794,9 @@ class WorkflowSpec:
"inputs": {name: port.to_dict() for name, port in self.inputs.items()}, "inputs": {name: port.to_dict() for name, port in self.inputs.items()},
"stages": [stage.to_dict() for stage in self.stages], "stages": [stage.to_dict() for stage in self.stages],
"edges": [edge.to_dict() for edge in self.edges], "edges": [edge.to_dict() for edge in self.edges],
"outputs": {name: reference.to_dict() for name, reference in self.outputs.items()}, "outputs": {
name: reference.to_dict() for name, reference in self.outputs.items()
},
"failure_policy": self.failure_policy.value, "failure_policy": self.failure_policy.value,
"max_tasks": self.max_tasks, "max_tasks": self.max_tasks,
"max_output_bytes": self.max_output_bytes, "max_output_bytes": self.max_output_bytes,
@@ -591,8 +807,15 @@ class WorkflowSpec:
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise ValueError("workflow specification must be an object") raise ValueError("workflow specification must be an object")
fields = { fields = {
"schema_version", "workflow_id", "inputs", "stages", "edges", "schema_version",
"outputs", "failure_policy", "max_tasks", "max_output_bytes", "workflow_id",
"inputs",
"stages",
"edges",
"outputs",
"failure_policy",
"max_tasks",
"max_output_bytes",
} }
require_exact_keys(value, fields, "workflow specification") require_exact_keys(value, fields, "workflow specification")
inputs, outputs = value["inputs"], value["outputs"] inputs, outputs = value["inputs"], value["outputs"]
@@ -607,7 +830,10 @@ class WorkflowSpec:
inputs={name: PortSpec.from_dict(port) for name, port in inputs.items()}, inputs={name: PortSpec.from_dict(port) for name, port in inputs.items()},
stages=tuple(StageSpec.from_dict(stage) for stage in stages), stages=tuple(StageSpec.from_dict(stage) for stage in stages),
edges=tuple(ArtifactEdge.from_dict(edge) for edge in edges), edges=tuple(ArtifactEdge.from_dict(edge) for edge in edges),
outputs={name: PortRef.from_dict(reference) for name, reference in outputs.items()}, outputs={
name: PortRef.from_dict(reference)
for name, reference in outputs.items()
},
failure_policy=value["failure_policy"], # type: ignore[arg-type] failure_policy=value["failure_policy"], # type: ignore[arg-type]
max_tasks=value["max_tasks"], # type: ignore[arg-type] max_tasks=value["max_tasks"], # type: ignore[arg-type]
max_output_bytes=value["max_output_bytes"], # type: ignore[arg-type] max_output_bytes=value["max_output_bytes"], # type: ignore[arg-type]
+23 -7
View File
@@ -22,6 +22,22 @@ def _clean_url(value: object | None) -> str | None:
return text.rstrip("/") or None return text.rstrip("/") or None
def _int_value(value: object | None, name: str) -> int | None:
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
raise ValueError(f"{name} must be a number")
return int(value)
def _float_value(value: object | None, name: str) -> float | None:
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
raise ValueError(f"{name} must be a number")
return float(value)
def _positive_number(value: object, name: str, *, allow_zero: bool = False) -> None: def _positive_number(value: object, name: str, *, allow_zero: bool = False) -> None:
if ( if (
isinstance(value, bool) isinstance(value, bool)
@@ -190,16 +206,16 @@ class WorkerConfig:
worker_id=str(worker_id) if worker_id is not None else None, worker_id=str(worker_id) if worker_id is not None else None,
work_dir=Path(str(work_dir)), work_dir=Path(str(work_dir)),
worker_name=str(worker_name), worker_name=str(worker_name),
cpu_count=int(cpu_count), cpu_count=_int_value(cpu_count, "cpu_count") or 1,
memory_mb=int(memory_mb) if memory_mb is not None else None, memory_mb=_int_value(memory_mb, "memory_mb"),
poll_interval=float(poll_interval), poll_interval=_float_value(poll_interval, "poll_interval") or 2.0,
request_timeout=float(request_timeout), request_timeout=_float_value(request_timeout, "request_timeout") or 30.0,
heartbeat_interval=float(heartbeat_interval), heartbeat_interval=_float_value(heartbeat_interval, "heartbeat_interval") or 15.0,
bearer_token=str(bearer_token) if bearer_token is not None else None, bearer_token=str(bearer_token) if bearer_token is not None else None,
worker_key=str(worker_key) if worker_key is not None else None, worker_key=str(worker_key) if worker_key is not None else None,
userservice_url=userservice_url, userservice_url=userservice_url,
cleanup_after_seconds=float(cleanup) if cleanup else None, cleanup_after_seconds=_float_value(cleanup, "cleanup_after_seconds"),
max_tasks=int(max_tasks) if max_tasks is not None else None, max_tasks=_int_value(max_tasks, "max_tasks"),
exit_when_idle=bool(values.get("exit_when_idle", False)), exit_when_idle=bool(values.get("exit_when_idle", False)),
capabilities=_capabilities(capabilities), capabilities=_capabilities(capabilities),
workload_allowlist=_workload_allowlist(allowlist), workload_allowlist=_workload_allowlist(allowlist),
+71 -25
View File
@@ -16,7 +16,11 @@ from datetime import datetime, timezone
from .artifacts import ArtifactClient, sha256_file from .artifacts import ArtifactClient, sha256_file
from .config import WorkerConfig from .config import WorkerConfig
from .coordinator import CoordinatorClient, CoordinatorConflictError, CoordinatorTransientError from .coordinator import (
CoordinatorClient,
CoordinatorConflictError,
CoordinatorTransientError,
)
from .models import ClaimedTask, UploadedArtifact from .models import ClaimedTask, UploadedArtifact
from .runners import Runner from .runners import Runner
@@ -24,8 +28,11 @@ from .runners import Runner
class LeaseHeartbeat: class LeaseHeartbeat:
"""Renews a claimed task lease while local work is in progress.""" """Renews a claimed task lease while local work is in progress."""
def __init__(self, task: ClaimedTask, coordinator: CoordinatorClient, config: WorkerConfig) -> None: def __init__(
self, task: ClaimedTask, coordinator: CoordinatorClient, config: WorkerConfig
) -> None:
self.task, self.coordinator, self.config = task, coordinator, config self.task, self.coordinator, self.config = task, coordinator, config
self._worker_id = config.worker_id or ""
self._stop = threading.Event() self._stop = threading.Event()
self._error: Exception | None = None self._error: Exception | None = None
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
@@ -33,11 +40,11 @@ class LeaseHeartbeat:
def start(self) -> None: def start(self) -> None:
# Verify ownership before expensive download or calculation begins. # Verify ownership before expensive download or calculation begins.
self._lease_expires_at = self.coordinator.heartbeat( self._lease_expires_at = self.coordinator.heartbeat(self.task, self._worker_id)
self.task, self.config.worker_id
)
self._next_delay() self._next_delay()
self._thread = threading.Thread(target=self._run, name=f"lease-{self.task.task_id}", daemon=True) self._thread = threading.Thread(
target=self._run, name=f"lease-{self.task.task_id}", daemon=True
)
self._thread.start() self._thread.start()
def stop(self) -> None: def stop(self) -> None:
@@ -54,10 +61,12 @@ class LeaseHeartbeat:
while not self._stop.wait(max(delay, 0.01)): while not self._stop.wait(max(delay, 0.01)):
try: try:
self._lease_expires_at = self.coordinator.heartbeat( self._lease_expires_at = self.coordinator.heartbeat(
self.task, self.config.worker_id self.task, self._worker_id
) )
delay = self._next_delay() delay = self._next_delay()
except Exception as error: # Surface the lease loss in the main state machine. except (
Exception
) as error: # Surface the lease loss in the main state machine.
self._error = error self._error = error
return return
@@ -66,7 +75,9 @@ class LeaseHeartbeat:
def _seconds_until_expiry(self) -> float: def _seconds_until_expiry(self) -> float:
try: try:
expiry = datetime.fromisoformat(self._lease_expires_at.replace("Z", "+00:00")) expiry = datetime.fromisoformat(
self._lease_expires_at.replace("Z", "+00:00")
)
except ValueError as error: except ValueError as error:
raise ValueError("invalid lease_expires_at") from error raise ValueError("invalid lease_expires_at") from error
seconds = (expiry - datetime.now(timezone.utc)).total_seconds() seconds = (expiry - datetime.now(timezone.utc)).total_seconds()
@@ -84,8 +95,19 @@ class RunOnceOutcome:
class WorkerDaemon: class WorkerDaemon:
def __init__(self, config: WorkerConfig, coordinator: CoordinatorClient, artifacts: ArtifactClient, runner: Runner) -> None: def __init__(
self.config, self.coordinator, self.artifacts, self.runner = config, coordinator, artifacts, runner self,
config: WorkerConfig,
coordinator: CoordinatorClient,
artifacts: ArtifactClient,
runner: Runner,
) -> None:
self.config, self.coordinator, self.artifacts, self.runner = (
config,
coordinator,
artifacts,
runner,
)
self.worker_id = config.worker_id self.worker_id = config.worker_id
self._registered = False self._registered = False
self.log = logging.getLogger("scimesh.worker") self.log = logging.getLogger("scimesh.worker")
@@ -129,14 +151,20 @@ class WorkerDaemon:
) )
return True return True
elif self.config.exit_when_idle: elif self.config.exit_when_idle:
self._log("stopped", reason="queue_empty", completed_tasks=completed_tasks) self._log(
"stopped",
reason="queue_empty",
completed_tasks=completed_tasks,
)
return True return True
else: else:
self._sleep(self.config.poll_interval) self._sleep(self.config.poll_interval)
except CoordinatorTransientError as error: except CoordinatorTransientError as error:
failures += 1 failures += 1
self._log("failed", error_type=type(error).__name__) self._log("failed", error_type=type(error).__name__)
self._sleep(min(self.config.poll_interval * 2 ** min(failures, 6), 60.0)) self._sleep(
min(self.config.poll_interval * 2 ** min(failures, 6), 60.0)
)
except KeyboardInterrupt: except KeyboardInterrupt:
self._log("stopped", reason="interrupted", completed_tasks=completed_tasks) self._log("stopped", reason="interrupted", completed_tasks=completed_tasks)
return False return False
@@ -183,11 +211,15 @@ class WorkerDaemon:
}, },
) )
completed = True completed = True
self._log("completed", task, elapsed_seconds=round(time.monotonic() - started, 3)) self._log(
"completed", task, elapsed_seconds=round(time.monotonic() - started, 3)
)
except KeyboardInterrupt: except KeyboardInterrupt:
self._log("interrupted", task) self._log("interrupted", task)
try: try:
self._report_failure(task, InterruptedError("worker interrupted by operator")) self._report_failure(
task, InterruptedError("worker interrupted by operator")
)
except CoordinatorTransientError: except CoordinatorTransientError:
self._log("failed", task, error_type="FailureReportError") self._log("failed", task, error_type="FailureReportError")
raise raise
@@ -203,13 +235,16 @@ class WorkerDaemon:
def _report_failure(self, task: ClaimedTask, error: Exception) -> None: def _report_failure(self, task: ClaimedTask, error: Exception) -> None:
message = self._sanitize_error_message(error) message = self._sanitize_error_message(error)
try: try:
self.coordinator.fail(task, { self.coordinator.fail(
"worker_id": self._worker_id(), task,
"attempt": task.attempt, {
"error_code": type(error).__name__, "worker_id": self._worker_id(),
"error_message": message, "attempt": task.attempt,
"retryable": self._is_retryable(error), "error_code": type(error).__name__,
}) "error_message": message,
"retryable": self._is_retryable(error),
},
)
except CoordinatorTransientError: except CoordinatorTransientError:
raise raise
except Exception: except Exception:
@@ -218,7 +253,9 @@ class WorkerDaemon:
@staticmethod @staticmethod
def _is_retryable(error: Exception) -> bool: def _is_retryable(error: Exception) -> bool:
"""Retry transient worker/transport failures, never invalid scientific input.""" """Retry transient worker/transport failures, never invalid scientific input."""
return not isinstance(error, (ValueError, FileNotFoundError, subprocess.CalledProcessError)) return not isinstance(
error, (ValueError, FileNotFoundError, subprocess.CalledProcessError)
)
def _sanitize_error_message(self, error: Exception) -> str: def _sanitize_error_message(self, error: Exception) -> str:
"""Keep coordinator-visible failures useful without exposing local paths.""" """Keep coordinator-visible failures useful without exposing local paths."""
@@ -264,12 +301,21 @@ class WorkerDaemon:
log_level: int = logging.INFO, log_level: int = logging.INFO,
**extra: object, **extra: object,
) -> None: ) -> None:
fields = {"worker_id": self.config.worker_id, "task_id": task.task_id if task else None, "attempt": task.attempt if task else None, "state": state, **extra} fields = {
"worker_id": self.config.worker_id,
"task_id": task.task_id if task else None,
"attempt": task.attempt if task else None,
"state": state,
**extra,
}
self.log.log(log_level, "worker_event %s", fields) self.log.log(log_level, "worker_event %s", fields)
def _cleanup_expired_directories(self) -> None: def _cleanup_expired_directories(self) -> None:
"""Remove only old task attempt directories when retention was configured.""" """Remove only old task attempt directories when retention was configured."""
if self.config.cleanup_after_seconds is None or not self.config.work_dir.exists(): if (
self.config.cleanup_after_seconds is None
or not self.config.work_dir.exists()
):
return return
cutoff = time.time() - self.config.cleanup_after_seconds cutoff = time.time() - self.config.cleanup_after_seconds
for task_dir in self.config.work_dir.iterdir(): for task_dir in self.config.work_dir.iterdir():
+4 -2
View File
@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
from http.client import HTTPMessage
from typing import IO
from urllib.request import HTTPRedirectHandler, Request from urllib.request import HTTPRedirectHandler, Request
from urllib.parse import urlsplit from urllib.parse import urlsplit
@@ -24,10 +26,10 @@ class SameOriginAuthRedirectHandler(HTTPRedirectHandler):
def redirect_request( def redirect_request(
self, self,
req: Request, req: Request,
fp: object, fp: IO[bytes],
code: int, code: int,
msg: str, msg: str,
headers: object, headers: HTTPMessage,
newurl: str, newurl: str,
) -> Request | None: ) -> Request | None:
redirected = super().redirect_request(req, fp, code, msg, headers, newurl) redirected = super().redirect_request(req, fp, code, msg, headers, newurl)
@@ -9,6 +9,7 @@ partition, compute, and merge.
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from typing import Any, Mapping from typing import Any, Mapping
from scimesh.sdk.artifacts import ArtifactSchema, ComponentRef, PortSpec from scimesh.sdk.artifacts import ArtifactSchema, ComponentRef, PortSpec
@@ -132,6 +133,7 @@ class DescriptorBatchWorkload(MapReduceWorkload):
raise ValueError("skip_invalid must be a boolean") raise ValueError("skip_invalid must be a boolean")
return value return value
def descriptor_batch_sdk_definition( def descriptor_batch_sdk_definition(
*, *,
shard_rows: int = 10_000, shard_rows: int = 10_000,
+7 -2
View File
@@ -12,7 +12,7 @@ import os
import platform import platform
from scimesh.sdk.identity import SDK_API_VERSION from scimesh.sdk.identity import SDK_API_VERSION
from scimesh.sdk.registry import WorkloadRegistry from scimesh.sdk.registry import AllowedPackage, WorkloadRegistry
from scimesh.sdk.resources import ResourceInventory from scimesh.sdk.resources import ResourceInventory
from scimesh.sdk.runtime import RuntimeCapabilities from scimesh.sdk.runtime import RuntimeCapabilities
@@ -80,7 +80,12 @@ def default_sdk_runtime(
features={"artifact-collections": "1.0.0", "exact-verifier": "1.0.0"}, features={"artifact-collections": "1.0.0", "exact-verifier": "1.0.0"},
workload_capabilities=( workload_capabilities=(
workload_capabilities workload_capabilities
or ("similarity-search", "similarity-graph", "descriptor-batch", "molwt-filter") or (
"similarity-search",
"similarity-graph",
"descriptor-batch",
"molwt-filter",
)
), ),
inventory=ResourceInventory( inventory=ResourceInventory(
cpu_cores=max(os.cpu_count() or 1, 1), cpu_cores=max(os.cpu_count() or 1, 1),
+1 -1
View File
@@ -68,7 +68,7 @@ def filter_molecules_by_molwt(
if not skip_invalid: if not skip_invalid:
raise ValueError(f"row {scanned} has an invalid canonical_smiles") raise ValueError(f"row {scanned} has an invalid canonical_smiles")
continue continue
molwt = Descriptors.MolWt(molecule) molwt = Descriptors.MolWt(molecule) # type: ignore[attr-defined]
if minimum is not None and molwt < minimum: if minimum is not None and molwt < minimum:
continue continue
if maximum is not None and molwt > maximum: if maximum is not None and molwt > maximum:
+5 -1
View File
@@ -1,9 +1,13 @@
from __future__ import annotations from __future__ import annotations
import pytest
from scimesh.cli import main from scimesh.cli import main
def test_help_command_prints_runnable_examples(capsys: object) -> None: def test_help_command_prints_runnable_examples(
capsys: pytest.CaptureFixture[str],
) -> None:
assert main(["help"]) == 0 assert main(["help"]) == 0
output = capsys.readouterr().out output = capsys.readouterr().out
assert "scimesh similarity-search" in output assert "scimesh similarity-search" in output
+9 -3
View File
@@ -4,10 +4,14 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
import pytest
from scimesh.cli import main from scimesh.cli import main
def test_workload_cli_lists_sdk_workloads(capsys: object) -> None: def test_workload_cli_lists_sdk_workloads(
capsys: pytest.CaptureFixture[str],
) -> None:
assert main(["workload", "list"]) == 0 assert main(["workload", "list"]) == 0
output = capsys.readouterr().out output = capsys.readouterr().out
assert "descriptor-batch" in output assert "descriptor-batch" in output
@@ -16,7 +20,9 @@ def test_workload_cli_lists_sdk_workloads(capsys: object) -> None:
assert "enabled" in output assert "enabled" in output
def test_workload_cli_runs_descriptor_batch(tmp_path: Path, capsys: object) -> None: def test_workload_cli_runs_descriptor_batch(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
dataset = tmp_path / "molecules.tsv" dataset = tmp_path / "molecules.tsv"
dataset.write_text( dataset.write_text(
"chembl_id\tcanonical_smiles\nA\tCCO\nB\tCCCC\nC\tCCN\n", "chembl_id\tcanonical_smiles\nA\tCCO\nB\tCCCC\nC\tCCN\n",
@@ -107,7 +113,7 @@ def test_workload_cli_rejects_invalid_params_json(tmp_path: Path) -> None:
def test_workload_cli_runs_an_allowlisted_custom_workload( def test_workload_cli_runs_an_allowlisted_custom_workload(
tmp_path: Path, monkeypatch: object, capsys: object tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None: ) -> None:
import csv import csv
+2 -1
View File
@@ -12,6 +12,7 @@ import pytest
from scimesh.chemistry.dataset import find_molecule_by_id from scimesh.chemistry.dataset import find_molecule_by_id
from scimesh.sdk import ( from scimesh.sdk import (
ArtifactCollection, ArtifactCollection,
WorkloadManifest,
ArtifactSchema, ArtifactSchema,
CheckpointPolicy, CheckpointPolicy,
CompatibilityError, CompatibilityError,
@@ -498,7 +499,7 @@ def test_local_executor_rejects_non_trusted_trust_modes(tmp_path: Path) -> None:
def _advanced_execution_manifest( def _advanced_execution_manifest(
original: WorkloadDefinition, original: WorkloadDefinition,
case: str, case: str,
) -> tuple[tuple[str, ...], object]: ) -> tuple[tuple[str, ...], WorkloadManifest]:
"""Declare one negotiable advanced profile the local executor cannot enforce.""" """Declare one negotiable advanced profile the local executor cannot enforce."""
stages = original.manifest.workflow.stages stages = original.manifest.workflow.stages
if case == "process-pool": if case == "process-pool":
+6 -6
View File
@@ -250,9 +250,9 @@ def test_json_backed_values_are_deeply_immutable_and_detached_from_callers() ->
with pytest.raises(TypeError): with pytest.raises(TypeError):
manifest.environment.metadata["python"]["version"][0] = 2 manifest.environment.metadata["python"]["version"][0] = 2
with pytest.raises(TypeError): with pytest.raises(TypeError):
manifest.inputs["another"] = manifest.inputs["dataset"] manifest.inputs["another"] = manifest.inputs["dataset"] # type: ignore[index]
with pytest.raises(FrozenInstanceError): with pytest.raises(FrozenInstanceError):
manifest.description = "changed" manifest.description = "changed" # type: ignore[misc]
def test_collection_kinds_have_distinct_ordering_key_and_duplicate_semantics() -> None: def test_collection_kinds_have_distinct_ordering_key_and_duplicate_semantics() -> None:
@@ -324,7 +324,7 @@ def test_workflow_graph_validation_fails_closed_for_unbound_or_inconsistent_depe
WorkflowSpec.from_dict(payload) WorkflowSpec.from_dict(payload)
payload = workload_manifest().workflow.to_dict() payload = workload_manifest().workflow.to_dict()
payload["stages"][0]["needs"] = ["undeclared-stage"] payload["stages"][0]["needs"] = ["undeclared-stage"] # type: ignore[index]
with pytest.raises(ValueError, match="needs do not match"): with pytest.raises(ValueError, match="needs do not match"):
WorkflowSpec.from_dict(payload) WorkflowSpec.from_dict(payload)
@@ -549,14 +549,14 @@ def test_expansion_is_bound_to_coordinator_parent_and_remaining_budget() -> None
source = ArtifactCollection.single(artifact("dynamic-source")) source = ArtifactCollection.single(artifact("dynamic-source"))
planned = ArtifactCollection.single(artifact("dynamic-planned")) planned = ArtifactCollection.single(artifact("dynamic-planned"))
parent = TaskSpec( parent = TaskSpec(
**common, **common, # type: ignore[arg-type]
task_key="root/planner", task_key="root/planner",
stage_id="planner", stage_id="planner",
inputs={"dataset": source}, inputs={"dataset": source},
expected_outputs={"planned": port}, expected_outputs={"planned": port},
) )
child = TaskSpec( child = TaskSpec(
**common, **common, # type: ignore[arg-type]
task_key="root/planner/000", task_key="root/planner/000",
stage_id="compute", stage_id="compute",
inputs={"dataset": planned}, inputs={"dataset": planned},
@@ -992,7 +992,7 @@ def test_allocation_environment_exposes_only_allocation_derived_values() -> None
"ROCR_VISIBLE_DEVICES": "GPU-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "ROCR_VISIBLE_DEVICES": "GPU-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
} }
with pytest.raises(TypeError): with pytest.raises(TypeError):
environment["OMP_NUM_THREADS"] = "1" environment["OMP_NUM_THREADS"] = "1" # type: ignore[index]
cpu_only = replace(allocation, accelerator_ids=()) cpu_only = replace(allocation, accelerator_ids=())
assert profile.allocation_environment(cpu_only)["CUDA_VISIBLE_DEVICES"] == "" assert profile.allocation_environment(cpu_only)["CUDA_VISIBLE_DEVICES"] == ""
+1
View File
@@ -87,6 +87,7 @@ def test_compatibility_failure_occurs_before_planner_invocation(
class CountingPlanner: class CountingPlanner:
calls = 0 calls = 0
entry_point = "tests.sdk_fixture:plan@v1"
def validate(self, request): def validate(self, request):
self.calls += 1 self.calls += 1
+1 -1
View File
@@ -92,7 +92,7 @@ def test_resource_inventory_and_requirements_round_trip_without_mutable_aliases(
capabilities["compute"] = "mutated" capabilities["compute"] = "mutated"
assert device.capabilities["compute"] == "9.0" assert device.capabilities["compute"] == "9.0"
with pytest.raises(TypeError): with pytest.raises(TypeError):
device.capabilities["compute"] = "mutated" device.capabilities["compute"] = "mutated" # type: ignore[index]
assert ResourceInventory.from_dict(inventory.to_dict()) == inventory assert ResourceInventory.from_dict(inventory.to_dict()) == inventory
assert ResourceRequirements.from_dict(requirements.to_dict()) == requirements assert ResourceRequirements.from_dict(requirements.to_dict()) == requirements
assert requirements.eligibility_errors(inventory) == () assert requirements.eligibility_errors(inventory) == ()
+1 -1
View File
@@ -523,7 +523,7 @@ def test_exact_verifier_rejects_owner_equivocation_without_leaking_identity() ->
"candidate_count": 3, "candidate_count": 3,
"equivocating_owner_count": 1, "equivocating_owner_count": 1,
} }
assert candidates.candidates[0].owner_id not in json.dumps(decision.to_dict()) assert (candidates.candidates[0].owner_id or "") not in json.dumps(decision.to_dict())
def test_numeric_verifier_accepts_nested_values_with_absolute_and_relative_tolerance() -> None: def test_numeric_verifier_accepts_nested_values_with_absolute_and_relative_tolerance() -> None:
+39 -23
View File
@@ -49,7 +49,9 @@ class SeqOpener:
def _exchange_response(token: str, expires_in: int) -> FakeResponse: def _exchange_response(token: str, expires_in: int) -> FakeResponse:
return FakeResponse(200, json.dumps({"token": token, "expires_in": expires_in}).encode()) return FakeResponse(
200, json.dumps({"token": token, "expires_in": expires_in}).encode()
)
def test_static_provider_returns_fixed_token_and_never_refreshes(): def test_static_provider_returns_fixed_token_and_never_refreshes():
@@ -68,13 +70,13 @@ def test_worker_key_provider_exchanges_once_then_caches():
provider = WorkerKeyTokenProvider( provider = WorkerKeyTokenProvider(
"http://users", "scimesh_wk_live_x", timeout=5, now=lambda: clock["t"] "http://users", "scimesh_wk_live_x", timeout=5, now=lambda: clock["t"]
) )
provider._opener = SeqOpener([_exchange_response("jwt-1", 100)]) provider._opener = SeqOpener([_exchange_response("jwt-1", 100)]) # type: ignore[reportAttributeAccessIssue]
# First call exchanges; a second call well within the TTL reuses the cache. # First call exchanges; a second call well within the TTL reuses the cache.
assert provider.token() == "jwt-1" assert provider.token() == "jwt-1"
clock["t"] = 1050.0 # 50s later, TTL 100s with 0.2 leeway → refresh at +80s clock["t"] = 1050.0 # 50s later, TTL 100s with 0.2 leeway → refresh at +80s
assert provider.token() == "jwt-1" assert provider.token() == "jwt-1"
assert len(provider._opener.requests) == 1 assert len(provider._opener.requests) == 1 # type: ignore[reportAttributeAccessIssue]
def test_worker_key_provider_refreshes_after_leeway(): def test_worker_key_provider_refreshes_after_leeway():
@@ -82,22 +84,26 @@ def test_worker_key_provider_refreshes_after_leeway():
provider = WorkerKeyTokenProvider( provider = WorkerKeyTokenProvider(
"http://users", "k", timeout=5, now=lambda: clock["t"] "http://users", "k", timeout=5, now=lambda: clock["t"]
) )
provider._opener = SeqOpener([ provider._opener = SeqOpener( # type: ignore[reportAttributeAccessIssue]
_exchange_response("jwt-1", 100), [
_exchange_response("jwt-2", 100), _exchange_response("jwt-1", 100),
]) _exchange_response("jwt-2", 100),
]
)
assert provider.token() == "jwt-1" assert provider.token() == "jwt-1"
clock["t"] = 85.0 # past the 80s refresh point clock["t"] = 85.0 # past the 80s refresh point
assert provider.token() == "jwt-2" assert provider.token() == "jwt-2"
assert len(provider._opener.requests) == 2 assert len(provider._opener.requests) == 2 # type: ignore[reportAttributeAccessIssue]
def test_worker_key_provider_force_refresh(): def test_worker_key_provider_force_refresh():
provider = WorkerKeyTokenProvider("http://users", "k", timeout=5, now=lambda: 0.0) provider = WorkerKeyTokenProvider("http://users", "k", timeout=5, now=lambda: 0.0)
provider._opener = SeqOpener([ provider._opener = SeqOpener( # type: ignore[reportAttributeAccessIssue]
_exchange_response("jwt-1", 100), [
_exchange_response("jwt-2", 100), _exchange_response("jwt-1", 100),
]) _exchange_response("jwt-2", 100),
]
)
assert provider.token() == "jwt-1" assert provider.token() == "jwt-1"
provider.refresh() provider.refresh()
assert provider.token() == "jwt-2" assert provider.token() == "jwt-2"
@@ -105,14 +111,18 @@ def test_worker_key_provider_force_refresh():
def test_worker_key_provider_raises_on_rejected_key(): def test_worker_key_provider_raises_on_rejected_key():
provider = WorkerKeyTokenProvider("http://users", "bad", timeout=5, now=lambda: 0.0) provider = WorkerKeyTokenProvider("http://users", "bad", timeout=5, now=lambda: 0.0)
provider._opener = SeqOpener([HTTPError("http://users", 401, "unauthorized", {}, None)]) provider._opener = SeqOpener( # type: ignore[reportAttributeAccessIssue]
[HTTPError("http://users", 401, "unauthorized", {}, None)] # type: ignore[reportArgumentType]
)
with pytest.raises(TokenExchangeError): with pytest.raises(TokenExchangeError):
provider.token() provider.token()
def test_worker_key_provider_raises_when_token_missing(): def test_worker_key_provider_raises_when_token_missing():
provider = WorkerKeyTokenProvider("http://users", "k", timeout=5, now=lambda: 0.0) provider = WorkerKeyTokenProvider("http://users", "k", timeout=5, now=lambda: 0.0)
provider._opener = SeqOpener([FakeResponse(200, json.dumps({"expires_in": 100}).encode())]) provider._opener = SeqOpener( # type: ignore[reportAttributeAccessIssue]
[FakeResponse(200, json.dumps({"expires_in": 100}).encode())]
)
with pytest.raises(TokenExchangeError): with pytest.raises(TokenExchangeError):
provider.token() provider.token()
@@ -152,10 +162,12 @@ class RefreshCountingProvider:
def test_coordinator_client_refreshes_and_retries_once_on_401(): def test_coordinator_client_refreshes_and_retries_once_on_401():
provider = RefreshCountingProvider() provider = RefreshCountingProvider()
client = HttpCoordinatorClient("http://coord", timeout=5, token_provider=provider) client = HttpCoordinatorClient("http://coord", timeout=5, token_provider=provider)
client._opener = SeqOpener([ client._opener = SeqOpener( # type: ignore[reportAttributeAccessIssue]
HTTPError("http://coord/tasks/claim", 401, "unauthorized", {}, None), [
FakeResponse(204, b""), HTTPError("http://coord/tasks/claim", 401, "unauthorized", {}, None), # type: ignore[reportArgumentType]
]) FakeResponse(204, b""),
]
)
status, _ = client._request("POST", "/tasks/claim", {"worker_id": "w"}) status, _ = client._request("POST", "/tasks/claim", {"worker_id": "w"})
@@ -168,10 +180,12 @@ def test_coordinator_client_refreshes_and_retries_once_on_401():
def test_coordinator_client_does_not_loop_on_persistent_401(): def test_coordinator_client_does_not_loop_on_persistent_401():
provider = RefreshCountingProvider() provider = RefreshCountingProvider()
client = HttpCoordinatorClient("http://coord", timeout=5, token_provider=provider) client = HttpCoordinatorClient("http://coord", timeout=5, token_provider=provider)
client._opener = SeqOpener([ client._opener = SeqOpener( # type: ignore[reportAttributeAccessIssue]
HTTPError("http://coord/x", 401, "unauthorized", {}, None), [
HTTPError("http://coord/x", 401, "unauthorized", {}, None), HTTPError("http://coord/x", 401, "unauthorized", {}, None), # type: ignore[reportArgumentType]
]) HTTPError("http://coord/x", 401, "unauthorized", {}, None), # type: ignore[reportArgumentType]
]
)
status, _ = client._request("POST", "/x", {}) status, _ = client._request("POST", "/x", {})
@@ -195,7 +209,9 @@ def test_worker_key_requires_userservice_url():
def test_worker_key_with_userservice_url_is_valid(): def test_worker_key_with_userservice_url_is_valid():
cfg = WorkerConfig(**_base_config(worker_key="scimesh_wk_live_x", userservice_url="http://users")) cfg = WorkerConfig(
**_base_config(worker_key="scimesh_wk_live_x", userservice_url="http://users")
)
assert cfg.worker_key == "scimesh_wk_live_x" assert cfg.worker_key == "scimesh_wk_live_x"
assert cfg.userservice_url == "http://users" assert cfg.userservice_url == "http://users"
+1 -1
View File
@@ -479,7 +479,7 @@ def test_redirect_to_external_storage_strips_authorization() -> None:
headers={"Authorization": "Bearer secret"}, headers={"Authorization": "Bearer secret"},
) )
redirected = handler.redirect_request( redirected = handler.redirect_request(
source, None, 302, "Found", {}, "https://bucket.example/presigned" source, None, 302, "Found", {}, "https://bucket.example/presigned" # type: ignore[arg-type]
) )
assert redirected is not None assert redirected is not None
assert redirected.get_header("Authorization") is None assert redirected.get_header("Authorization") is None