diff --git a/Cargo.lock b/Cargo.lock index 66d28bd..b6c0bdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1669,6 +1669,7 @@ dependencies = [ "clap", "flate2", "lab-compiler", + "lab-inventory", "lab-package", "lab-project", "lab-runfmt", @@ -1697,14 +1698,17 @@ dependencies = [ "clap", "hamilton-star", "lab-instruments", + "lab-inventory", "lab-language", "lab-runfmt", "opentrons-protocol", "pliron", + "sbol-inventory", "schemars", "serde", "serde_json", "sha2", + "tempfile", "thiserror", "toml 0.8.23", ] @@ -1740,6 +1744,18 @@ dependencies = [ "thiserror", ] +[[package]] +name = "lab-inventory" +version = "0.1.2" +dependencies = [ + "lab-package", + "sbol-inventory", + "sbol3", + "sha2", + "tempfile", + "thiserror", +] + [[package]] name = "lab-language" version = "0.1.2" @@ -1813,11 +1829,16 @@ dependencies = [ "anyhow", "hamilton-star", "lab-instruments", + "lab-inventory", "lab-runfmt", + "sbol-inventory", + "sbol3", "serde", "serde_json", + "sha2", "tempfile", "thiserror", + "time", ] [[package]] @@ -2886,18 +2907,25 @@ dependencies = [ [[package]] name = "sbol-core" version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8ce81427537a371614e75990f4bd232529f8723194ee3f851da920485893cf" +source = "git+https://github.com/SynBioDex/sbol-rs?rev=2ecae3718ebb87dbdbf7112ed4d7f42c0155eea4#2ecae3718ebb87dbdbf7112ed4d7f42c0155eea4" dependencies = [ "sbol-rdf", "thiserror", ] +[[package]] +name = "sbol-inventory" +version = "0.1.0" +source = "git+https://github.com/SynBioDex/sbol-rs?rev=2ecae3718ebb87dbdbf7112ed4d7f42c0155eea4#2ecae3718ebb87dbdbf7112ed4d7f42c0155eea4" +dependencies = [ + "sbol3", + "thiserror", +] + [[package]] name = "sbol-ontology" version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f96778ec4bf56dcdec299e44a5cda7b6b44f467fa0e123229596837493f5584c" +source = "git+https://github.com/SynBioDex/sbol-rs?rev=2ecae3718ebb87dbdbf7112ed4d7f42c0155eea4#2ecae3718ebb87dbdbf7112ed4d7f42c0155eea4" dependencies = [ "clap", "sha2", @@ -2907,8 +2935,7 @@ dependencies = [ [[package]] name = "sbol-rdf" version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "319f0eb87a0386c973af337435f43eb64e970f1b118a0275564845d7f46f4a18" +source = "git+https://github.com/SynBioDex/sbol-rs?rev=2ecae3718ebb87dbdbf7112ed4d7f42c0155eea4#2ecae3718ebb87dbdbf7112ed4d7f42c0155eea4" dependencies = [ "oxjsonld", "oxrdf", @@ -2919,8 +2946,7 @@ dependencies = [ [[package]] name = "sbol-rulegen" version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14fb61f94454a8c1d0491f19a67c21c578ddf4c1ec8fcda6e56fefaccb10fe68" +source = "git+https://github.com/SynBioDex/sbol-rs?rev=2ecae3718ebb87dbdbf7112ed4d7f42c0155eea4#2ecae3718ebb87dbdbf7112ed4d7f42c0155eea4" dependencies = [ "serde", "toml 1.1.4+spec-1.1.0", @@ -2929,8 +2955,7 @@ dependencies = [ [[package]] name = "sbol3" version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80bf17db3038c3905c78c82d5200200cb5886c2f47bfd503bf3a53484fede5c7" +source = "git+https://github.com/SynBioDex/sbol-rs?rev=2ecae3718ebb87dbdbf7112ed4d7f42c0155eea4#2ecae3718ebb87dbdbf7112ed4d7f42c0155eea4" dependencies = [ "sbol-core", "sbol-ontology", diff --git a/Cargo.toml b/Cargo.toml index 6236dd2..c8808bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/lab-compiler", "crates/lab-ide", "crates/lab-ide-wasm", + "crates/lab-inventory", "crates/lab-instruments", "crates/lab-language", "crates/lab-language-server", @@ -40,9 +41,11 @@ rusb = "0.9" # SBOL 3 terms, serialization, and validation. Designs are read from and # written to SBOL documents, so the standard's object model is the compiler's # own rather than something a backend projects onto at the end. -sbol3 = "1" +sbol3 = { git = "https://github.com/SynBioDex/sbol-rs", rev = "2ecae3718ebb87dbdbf7112ed4d7f42c0155eea4" } +sbol-inventory = { git = "https://github.com/SynBioDex/sbol-rs", rev = "2ecae3718ebb87dbdbf7112ed4d7f42c0155eea4" } semver = "1" thiserror = "2" +time = { version = "0.3", features = ["formatting"] } toml = "0.8" lsp-server = "0.7" lsp-types = "0.97" @@ -75,6 +78,7 @@ hamilton-star = { version = "0.1.0", default-features = false } lab-ide = { path = "crates/lab-ide", version = "0.1.2" } inheco-sila = "0.1.0" lab-ide-wasm = { path = "crates/lab-ide-wasm", version = "0.1.2" } +lab-inventory = { path = "crates/lab-inventory", version = "0.1.2" } lab-instruments = { path = "crates/lab-instruments", version = "0.1.2" } lab-language = { path = "crates/lab-language", version = "0.1.2" } lab-language-server = { path = "crates/lab-language-server", version = "0.1.2" } diff --git a/README.md b/README.md index eb82160..b9f716d 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ SBOL is not a third way in. It is the vocabulary designs are written and exchang Lab is working toward a world in which laboratory work is portable, inspectable, and reliable across manual benches, automation, and cloud labs. -Today, protocols commonly entangle scientific intent with site-specific procedures. Lab separates them. A program describes biological designs, physical materials, workflows, and acceptance criteria; the compiler progressively specializes that program for the capabilities, policies, inventory, and hardware of a target laboratory. +Today, protocols commonly entangle scientific intent with site-specific procedures. Lab separates them. A program describes biological designs, physical materials, workflows, and acceptance criteria; the compiler progressively specializes that program against the capabilities, policies, inventory, and hardware of a selected facility. One biological program should be adaptable to many valid execution environments without erasing what the scientist meant. @@ -35,7 +35,7 @@ Lab treats laboratory automation as a compilation and control problem: - the type system models biological artifacts, physical materials, durable effects, and evidence; - two frontends, Python and Lab, lower to one checked module, which is the portable boundary nothing downstream reaches behind; -- **LAIR**, the Lab Automation Intermediate Representation, preserves meaning as programs are progressively lowered from portable intent to target-specific operations; +- **LAIR**, the Lab Automation Intermediate Representation, preserves meaning as programs are progressively lowered from portable intent to method-selected procedures and facility-bound device operations; - the compiler checks types, action contracts, and material ownership while keeping specialization decisions inspectable; - a durable runtime will execute idempotent actions, recover around failures, react to observations, and preserve lineage from intent to outcome. diff --git a/crates/lab-cli/Cargo.toml b/crates/lab-cli/Cargo.toml index 271f1d6..62132c4 100644 --- a/crates/lab-cli/Cargo.toml +++ b/crates/lab-cli/Cargo.toml @@ -16,6 +16,7 @@ anyhow.workspace = true clap.workspace = true flate2 = "1.1.9" lab-compiler.workspace = true +lab-inventory.workspace = true lab-runfmt.workspace = true # The runner needs the live USB transport the workspace dependency keeps # off by default. diff --git a/crates/lab-cli/README.md b/crates/lab-cli/README.md index 27f690c..5674940 100644 --- a/crates/lab-cli/README.md +++ b/crates/lab-cli/README.md @@ -19,7 +19,9 @@ lab check lab build ``` -`lab.toml` anchors package identity and the build entry. Source modules are discovered recursively under `src/` and receive stable names from their package and relative path. Same-package imports and recursive path dependencies are compiled through checked module interfaces. `lab build` writes verified portable module IR plus a package index under `.lab/build/` and a deterministic `lab.lock` at the project root. +`lab.toml` anchors package identity and the build entry. Source modules are discovered recursively under `src/` and receive stable names from their package and relative path. Same-package imports and recursive path dependencies are compiled through checked module interfaces. Every `lab build` writes verified portable module IR, `capability_requirements.json`, `capability_instances.json` for runnable packages, an optional `adapter_bindings.json`, and a package index under `.lab/build/`, plus a deterministic `lab.lock` at the project root. The requirement file describes every checked workflow template and contains no facility allocation. The instance file expands only templates reachable from the exact entry module's `main` workflow, preserves every resolved workflow call site, and rejects recursive expansion rather than inventing a finite run. When the runnable package selects an SBOLInventory document, the same build then allocates those instances, invokes adapters selected through exact Asset bindings, emits their protocol bundles and PDFs, and freezes everything in `.lab/build/plan.execution.json`. The package index's optional `facility` section records relative paths to the allocation, lowering manifest, reviewed plan, bundle roots, protocols, and PDFs. + +`lab plan` exposes that facility phase as a separate command, writing it under `.lab/plan/` without the portable module bundle. It requires an SBOLInventory document, applies the package's exact facility selector, allocates every reachable capability instance to one exact offering and Asset, and writes `facility_allocation.json` plus a validated `plan.execution.json`. Candidate ordering never chooses an asset: zero eligible offerings is an explained failure, and several eligible offerings require an explicit allocation policy. A planning-only or manual facility needs no adapter declaration. When an allocated Asset has a compatible lowering adapter, both `lab build` and `lab plan` emit its protocols and freeze its exact driver, profile, triggering requirements, child paths, formats, and digests in the reviewed plan. A `lab.toml` may instead declare a workspace, grouping member packages under one root: @@ -31,48 +33,43 @@ default-member = "packages/device" A workspace root owns membership and nothing else; each member stays an ordinary package. `default-member` names the package a single-package command acts on, and is required once a workspace has more than one member. -## Building for a bench - -`lab build --target ` reads `targets/.toml`, lowers the default member and everything it depends on as one program, and hands the verified result to the backend that profile names: - -```sh -lab build --target opentrons-ot2 -``` +## Facility-derived lowering -The target's artifacts are written under `.lab/build//`, and the build prints the path of every runnable automation protocol it emitted, ready to hand to an instrument application. A target profile describes the laboratory — modules, labware, deck slots, pipettes, mounts, and capacity — and never the science; reaction chemistry belongs to the designs in `src/`. Every profile field defaults to the backend's reference bench, so a profile states only what differs, and unknown keys are rejected rather than ignored. A profile's filename is its name; the file itself declares only which backend consumes it. +The portable frontend of `lab build` never contains an instrument selector: `[build]` names only the experiment entry, and the CLI has no `--target` mode. When the package names a facility document, the build derives device choice by matching the experiment's capability requirements against that validated facility. With no facility document, the same command stops after portable compilation. -Editors and control planes use the compiler-owned target contract rather than copying backend structs. It reports each backend's JSON Schema, complete default, catalog choices, capabilities, and workcell station kinds; validation runs the same cross-field semantics as a build and returns canonical TOML, canonical JSON, the compiler and schema versions, and a SHA-256: +An `[inventory]` table selects the SBOLInventory graph. Local adapter declarations connect exact Asset IRIs in that graph to installed Lab implementations: -```sh -lab targets describe -lab --json targets describe -lab targets default opentrons.flex --name flex-bay-1 -lab targets validate targets/flex-bay-1.toml -lab --json targets render targets/flex-bay-1.toml +```toml +[inventory] +document = "inventory/facility.ttl" +# Required when the document has several facilities: +facility = "https://example.org/facilities/example-lab" + +[[execution.adapters]] +asset = "https://example.org/facilities/example-lab/star-1" +driver = "hamilton.star" +profile = "adapters/star-1.toml" ``` -The shipped backends are `opentrons.ot2`, `opentrons.flex`, `hamilton.star`, and `workcell`. `describe` is the discovery authority for the exact compiler binary in use; consumers should not assume that list remains fixed. +Each adapter declaration binds an implementation to one exact catalog Asset. Facility facts remain in RDF, driver selection is never inferred from product metadata, and endpoints and credentials remain local runtime configuration. The old symbolic `materials` and `artifacts` arrays are accepted only as a mutually exclusive migration form. -A package that usually compiles for one bench names it in the manifest instead of on every invocation: +The facility is the lowering surface. The facility phase shared by `lab build` and `lab plan` resolves exact MaterialLots, allocates requirements to CapabilityOfferings and their owning Assets, and invokes only the adapters attached to those selected Assets. The reviewed plan freezes the inventory, allocation, staged adapter profiles, and every emitted device and support artifact by SHA-256. Whole-program adapters produce one reviewed lowering bundle covering all of their triggering requirements; Lab does not pretend that one generated protocol corresponds to one arbitrary requirement. -```toml -[build] -entry = "src/programs/main.lab" -target = "opentrons-ot2" +```sh +lab build +lab run .lab/build --dry-run ``` -`lab build` then produces that bench's protocols, `--target ` compiles for a different one, and `--no-target` stops at portable module IR. - -An `[inventory]` table states what the laboratory has on hand, and a target build resolves every artifact dependency against it: +`lab adapters describe` is the discovery authority for the exact compiler binary. Its `lab.adapter-catalog.v1` output keeps semantic SBOLInventory capability IRIs separate from implementation features and declares accepted control modes, emitted document formats, configuration schemas, and actual planning, lowering, simulation, and runtime services. The explicit driver argument selects validation code; neither an adapter profile nor an Asset's manufacturer or model can select another implementation. `lab.adapter-profile.v2` contains no backend or Asset selector, rejects the removed `[target]` table, and places OT-2 API-version configuration under `[protocol]`. -```toml -[inventory] -materials = ["BsaI", "T4_DNA_ligase", "pSB1C3"] -artifacts = ["composite_plasmid_1"] +```sh +lab adapters describe +lab --json adapters describe --driver opentrons.flex +lab adapters default opentrons.flex --name flex-bay-1 +lab adapters validate opentrons.flex adapters/flex-bay-1.toml +lab --json adapters render opentrons.flex adapters/flex-bay-1.toml ``` -`materials` are consumables a reaction may draw on; `artifacts` are already realized and are not built again. Both default to empty, so a package that declares no inventory builds everything from nothing and reports what it is missing. - All read-oriented commands support `--json` for editor and automation clients: ```sh @@ -82,4 +79,4 @@ lab check --json Path dependencies may optionally carry a semver requirement, which is checked against the dependency manifest. Registry dependencies remain explicitly unsupported and fail closed; adding them requires a registry protocol and integrity model rather than silent fallback. -`lab run --dry-run` validates and narrates reviewed Hamilton STAR or workcell run documents without touching hardware. A live workcell run connects the supported stations, confirms every handoff with the operator, and appends a durable node ledger so `--resume` can continue without repeating completed motion. +`lab run --dry-run` validates the frozen inventory, exact bindings, adapter profiles, every reviewed child artifact, and the dependency DAG, then narrates every node without touching hardware. `lab run --simulate` walks the same exact plan through simulation adapters and writes `inventory-simulation.ttl`; `lab run ` uses live executors and writes `inventory-after.ttl`. Both modes append a durable node ledger so `--resume` can continue without repeating completed work, but simulation and live ledgers are intentionally incompatible. Material movements and manual nodes remain explicit operator confirmations in either mode. diff --git a/crates/lab-cli/src/adapters.rs b/crates/lab-cli/src/adapters.rs new file mode 100644 index 0000000..7bb9408 --- /dev/null +++ b/crates/lab-cli/src/adapters.rs @@ -0,0 +1,189 @@ +//! Adapter discovery and profile validation commands. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use lab_compiler::backend::{ + AdapterCatalog, AdapterDescriptor, ValidatedAdapterProfile, adapter_catalog, + default_adapter_profile, validate_adapter_profile, +}; +use lab_compiler::planning::{AdapterBindingRequest, AdapterBindingSnapshot}; +use lab_inventory::InventorySnapshot; +use lab_package::LabPackage; + +use crate::Output; + +pub(crate) fn describe(driver: Option, output: &Output) -> Result<()> { + let catalog = adapter_catalog()?; + match driver { + Some(driver) => { + let descriptor = catalog + .adapters + .into_iter() + .find(|adapter| adapter.id == driver) + .with_context(|| format!("this compiler does not provide adapter '{driver}'"))?; + let human = render_descriptor(&descriptor); + output.success("adapter-described", descriptor, human) + } + None => { + let human = render_catalog(&catalog); + output.success("adapters-described", catalog, human) + } + } +} + +pub(crate) fn default(driver: String, name: String, output: &Output) -> Result<()> { + let profile = default_adapter_profile(&driver, &name)?; + let human = profile.canonical_toml.clone(); + output.success("adapter-default", profile, human) +} + +pub(crate) fn validate(driver: String, path: PathBuf, output: &Output) -> Result<()> { + let profile = load_and_validate(&driver, &path)?; + let human = format!( + "Validated {} as {}\n schema: {}\n sha256: {}", + path.display(), + profile.driver, + profile.schema_version, + profile.sha256 + ); + output.success("adapter-validated", profile, human) +} + +pub(crate) fn render(driver: String, path: PathBuf, output: &Output) -> Result<()> { + let profile = load_and_validate(&driver, &path)?; + let human = profile.canonical_toml.clone(); + output.success("adapter-rendered", profile, human) +} + +pub(crate) fn load_and_validate(driver: &str, path: &Path) -> Result { + if !path.is_file() { + bail!("no adapter profile at {}", path.display()); + } + let contents = + fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; + let name = path + .file_stem() + .and_then(|name| name.to_str()) + .context("an adapter profile file needs a UTF-8 file name")?; + validate_adapter_profile(driver, name, &contents).map_err(Into::into) +} + +pub(crate) fn resolve_package_bindings( + package: &LabPackage, + inventory: &InventorySnapshot, +) -> Result> { + if package.manifest.execution.adapters.is_empty() { + return Ok(None); + } + let canonical_root = fs::canonicalize(&package.root) + .with_context(|| format!("failed to resolve package root {}", package.root.display()))?; + let mut requests = Vec::new(); + for binding in &package.manifest.execution.adapters { + let joined = canonical_root.join(&binding.profile); + let profile_path = fs::canonicalize(&joined).with_context(|| { + format!( + "asset '{}' binds adapter '{}', but its profile cannot be read at {}", + binding.asset, + binding.driver, + joined.display() + ) + })?; + if !profile_path.starts_with(&canonical_root) { + bail!( + "adapter profile '{}' resolves outside package '{}'", + binding.profile.display(), + package.manifest.package.name + ); + } + let contents = fs::read_to_string(&profile_path) + .with_context(|| format!("failed to read {}", profile_path.display()))?; + let name = binding + .profile + .file_stem() + .and_then(|name| name.to_str()) + .context("an adapter profile file needs a UTF-8 file name")?; + let profile = + validate_adapter_profile(&binding.driver, name, &contents).with_context(|| { + format!( + "asset '{}' has invalid '{}' adapter profile {}", + binding.asset, + binding.driver, + binding.profile.display() + ) + })?; + requests.push(AdapterBindingRequest { + asset: binding.asset.clone(), + driver: binding.driver.clone(), + profile_path: binding.profile.clone(), + profile, + }); + } + AdapterBindingSnapshot::resolve(inventory, requests) + .map(Some) + .context("failed to bind configured adapters to SBOLInventory capability offerings") +} + +fn render_catalog(catalog: &AdapterCatalog) -> String { + let mut lines = vec![format!( + "Lab {} adapters ({})", + catalog.compiler_version, catalog.profile_schema_version + )]; + for adapter in &catalog.adapters { + let services = [ + adapter.services.planning.then_some("planning"), + adapter.services.lowering.then_some("lowering"), + adapter.services.simulation.then_some("simulation"), + adapter.services.runtime.then_some("runtime"), + ] + .into_iter() + .flatten() + .collect::>() + .join(", "); + lines.push(format!( + " {:<24} {} ({services})", + adapter.id, adapter.display_name + )); + } + lines.join("\n") +} + +fn render_descriptor(adapter: &AdapterDescriptor) -> String { + format!( + "{}\n driver: {}\n capabilities: {}\n features: {}\n control modes: {}\n accepts: {}\n emits: {}\n\n{}", + adapter.display_name, + adapter.id, + join(&adapter.capabilities), + join(&adapter.features), + join(&adapter.control_modes), + join(&adapter.accepted_run_formats), + join(&adapter.emitted_run_formats), + adapter.default_profile.canonical_toml + ) +} + +fn join(values: &std::collections::BTreeSet) -> String { + if values.is_empty() { + "none".to_owned() + } else { + values.iter().cloned().collect::>().join(", ") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_validation_uses_the_explicit_driver_and_file_stem() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("star-1.toml"); + fs::write(&path, "").unwrap(); + + let profile = load_and_validate("hamilton.star", &path).unwrap(); + + assert_eq!(profile.name, "star-1"); + assert_eq!(profile.driver, "hamilton.star"); + } +} diff --git a/crates/lab-cli/src/commands.rs b/crates/lab-cli/src/commands.rs index 4aec666..fbd6ff8 100644 --- a/crates/lab-cli/src/commands.rs +++ b/crates/lab-cli/src/commands.rs @@ -2,15 +2,19 @@ use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; -use lab_compiler::backend::hamilton::star::StarTargetProfile; -use lab_compiler::backend::{TargetProfile, parse_target_profile}; -use lab_compiler::planning::BuildInventory; +use lab_compiler::planning::{ + CapabilityRequirements, ExecutionPlanOptions, FacilityAllocation, build_execution_plan, + reviewed_lowering_bundles, +}; use lab_compiler::{ - DiagnosticSeverity, PortableLairProgram, SourceId, analyze_module, render_diagnostic, + CheckedDeclaration, DiagnosticSeverity, SourceId, analyze_module, render_diagnostic, }; +use lab_inventory::InventorySnapshot; use lab_package::{LabPackage, PackageManifest}; -use lab_project::{CompiledProject, LOCK_FILE, LabProject}; +use lab_project::{CompiledModule, CompiledProject, LOCK_FILE, LabProject}; +use lab_runfmt::{EXECUTION_PLAN_FILE, ExecutionPlanDocument}; use serde::Serialize; +use sha2::{Digest, Sha256}; use crate::Output; @@ -100,6 +104,7 @@ pub(crate) fn check(path: PathBuf, output: &Output) -> Result<()> { let project = LabProject::discover(&path) .with_context(|| format!("failed to load project from {}", path.display()))?; + validate_project_inventories(&project)?; let compiled = project.compile()?; let package = project.default_package(); output.success( @@ -119,24 +124,12 @@ pub(crate) fn check(path: PathBuf, output: &Output) -> Result<()> { ) } -pub(crate) fn build( - path: PathBuf, - out_dir: Option, - target: Option, - no_target: bool, - output: &Output, -) -> Result<()> { +pub(crate) fn build(path: PathBuf, out_dir: Option, output: &Output) -> Result<()> { let project = LabProject::discover(&path) .with_context(|| format!("failed to load project from {}", path.display()))?; + validate_project_inventories(&project)?; let compiled = project.compile()?; let package = project.default_package(); - // A named target wins over the manifest's default, and `--no-target` asks - // for portable module IR alone. - let target = if no_target { - None - } else { - target.or_else(|| package.manifest.build.target.clone()) - }; let project_root = project.root().to_path_buf(); let output_root = match out_dir { Some(path) if path.is_absolute() => path, @@ -146,6 +139,53 @@ pub(crate) fn build( fs::create_dir_all(&output_root) .with_context(|| format!("failed to create {}", output_root.display()))?; + let program_packages = project.program_packages(); + let products = build_products(&compiled.modules, &program_packages); + let program_modules = compiled + .modules + .iter() + .filter(|module| program_packages.contains(&module.package)) + .map(|module| &module.module) + .collect::>(); + let capability_requirements = CapabilityRequirements::extract(&program_modules) + .context("failed to derive workflow capability requirements")?; + let capability_requirements_artifact = PathBuf::from("capability_requirements.json"); + let capability_requirements_path = output_root.join(&capability_requirements_artifact); + let mut capability_requirements_json = serde_json::to_string_pretty(&capability_requirements)?; + capability_requirements_json.push('\n'); + fs::write(&capability_requirements_path, capability_requirements_json) + .with_context(|| format!("failed to write {}", capability_requirements_path.display()))?; + + let capability_instances_artifact = if let Some(entry) = package.entry_source() { + let instances = capability_requirements + .instantiate_reachable(&program_modules, &entry.module, "main") + .context("failed to instantiate reachable workflow capability requirements")?; + let artifact = PathBuf::from("capability_instances.json"); + let path = output_root.join(&artifact); + let mut json = serde_json::to_string_pretty(&instances)?; + json.push('\n'); + fs::write(&path, json).with_context(|| format!("failed to write {}", path.display()))?; + Some(artifact) + } else { + None + }; + + let adapter_bindings_artifact = if let Some(snapshot) = package_inventory_snapshot(package)? { + if let Some(bindings) = crate::adapters::resolve_package_bindings(package, &snapshot)? { + let artifact = PathBuf::from("adapter_bindings.json"); + let path = output_root.join(&artifact); + let mut json = serde_json::to_string_pretty(&bindings)?; + json.push('\n'); + fs::write(&path, json) + .with_context(|| format!("failed to write {}", path.display()))?; + Some(artifact) + } else { + None + } + } else { + None + }; + let mut artifacts = Vec::new(); for compiled_module in &compiled.modules { let source = &compiled_module.source; @@ -170,14 +210,28 @@ pub(crate) fn build( }); } + let facility = + if package.manifest.inventory.document.is_some() && package.entry_source().is_some() { + Some(write_facility_plan(&project, &compiled, &output_root)?) + } else { + None + }; + let facility_index = facility + .as_ref() + .map(|planned| build_facility_index(planned, &output_root)) + .transpose()?; let index = BuildIndex { - schema_version: 2, + schema_version: 6, package: package.manifest.package.name.clone(), version: package.manifest.package.version.clone(), edition: package.manifest.package.edition.clone(), entry: package.manifest.build.entry.clone(), members: compiled.members.clone(), modules: artifacts, + capability_requirements: capability_requirements_artifact, + capability_instances: capability_instances_artifact, + adapter_bindings: adapter_bindings_artifact, + facility: facility_index, }; let index_path = output_root.join("package.json"); let mut json = serde_json::to_string_pretty(&index)?; @@ -189,49 +243,36 @@ pub(crate) fn build( fs::write(&lock_path, lock) .with_context(|| format!("failed to write {}", lock_path.display()))?; - let built = match &target { - Some(target) => Some(build_for_target( - &project, - &compiled, - &project_root, - &output_root, - target, - )?), - None => None, - }; - let mut human = format!( - "Built {} {} ({} modules)\n Artifacts: {}", + "Built {} {} ({} modules)", index.package, index.version, - index.modules.len(), - output_root.display() + index.modules.len() ); - if let Some(built) = &built { + if products.is_empty() { + human.push_str("\n\nBuild products: none"); + } else { + human.push_str("\n\nBuild products:"); + for product in &products { + human.push_str(&format!("\n {} {}", product.kind, product.name)); + } + } + human.push_str(&format!( + "\n\nCompiler output: {}", + human_path(&output_root) + )); + if let Some(facility) = &facility { human.push_str(&format!( - "\n Target {}: {}", - target.as_deref().unwrap_or_default(), - built.directory.display() + "\n\nFacility outputs:\n Facility: {}\n Requirements allocated: {}\n Adapter lowerings: {}\n Allocation: {}\n Lowering manifest: {}\n Reviewed plan: {}", + facility.facility, + facility.allocated_requirements, + facility.adapter_lowerings, + human_path(&facility.allocation), + human_path(&facility.lowering), + human_path(&facility.execution_plan) )); - // Name every runnable protocol, so the path can go straight into a - // device application without hunting through the output directory. - if !built.protocols.is_empty() { - human.push_str("\n\nAutomation protocols:"); - for protocol in &built.protocols { - human.push_str(&format!("\n {}", protocol.display())); - } - } - if !built.documents.is_empty() { - human.push_str("\n\nDocuments:"); - for document in &built.documents { - human.push_str(&format!("\n {}", document.display())); - } - } + append_facility_artifacts(&mut human, facility); } - let (target_output, protocols, documents) = match built { - Some(built) => (Some(built.directory), built.protocols, built.documents), - None => (None, Vec::new(), Vec::new()), - }; output.success( "built", BuildCompleted { @@ -239,60 +280,79 @@ pub(crate) fn build( version: index.version.clone(), modules: index.modules.len(), output: output_root.clone(), - target, - target_output, - protocols, - documents, + products, + facility, }, human, ) } -/// What a target build produced: its package directory, every protocol a -/// device application can open, and the typeset operator documents. -struct TargetBuild { - directory: PathBuf, - protocols: Vec, - documents: Vec, +/// Every biological artifact that the compiled program declares with `build`. +/// Bought declarations lower to catalog entries instead and therefore never +/// appear in this summary. +fn build_products(modules: &[CompiledModule], program_packages: &[String]) -> Vec { + modules + .iter() + .filter(|module| program_packages.contains(&module.package)) + .flat_map(|module| { + module.module.declarations.iter().filter_map(|declaration| { + let CheckedDeclaration::Artifact { artifact, name, .. } = declaration else { + return None; + }; + Some(BuildProduct { + package: module.package.clone(), + module: module.source.module.clone(), + kind: artifact.clone(), + name: name.clone(), + }) + }) + }) + .collect() } -/// A generated artifact is an automation protocol when it follows the emitters' -/// naming convention, whatever format the backend writes. -fn is_automation_protocol(path: &Path) -> bool { - path.file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| { - name.ends_with("_protocol.py") - || name.ends_with("_protocol.json") - || name.ends_with(".star.json") - || name.ends_with(".odtc.json") - || name.ends_with(".read.json") - || name == "plan.workcell.json" - }) +pub(crate) fn plan(path: PathBuf, out_dir: Option, output: &Output) -> Result<()> { + let project = LabProject::discover(&path) + .with_context(|| format!("failed to load project from {}", path.display()))?; + let compiled = project.compile()?; + let project_root = project.root(); + let output_root = match out_dir { + Some(path) if path.is_absolute() => path, + Some(path) => project_root.join(path), + None => project_root.join(".lab").join("plan"), + }; + let planned = write_facility_plan(&project, &compiled, &output_root)?; + let mut human = format!( + "Planned {} {} against {}\n Requirements: {}\n Adapter lowerings: {}\n Plan output: {}\n Reviewed plan: {}", + planned.package, + planned.version, + planned.facility, + planned.allocated_requirements, + planned.adapter_lowerings, + human_path(&planned.output), + human_path(&planned.execution_plan) + ); + append_facility_artifacts(&mut human, &planned); + output.success("planned", planned, human) } -/// Lower the program the default member forms, together with everything it -/// depends on, and hand the verified Protocol to the named target's backend. -fn build_for_target( +fn write_facility_plan( project: &LabProject, compiled: &CompiledProject, - project_root: &Path, output_root: &Path, - target: &str, -) -> Result { - let profile_path = project_root.join("targets").join(format!("{target}.toml")); - let profile = if profile_path.is_file() { - let contents = fs::read_to_string(&profile_path) - .with_context(|| format!("failed to read {}", profile_path.display()))?; - parse_target_profile(target, &contents) - .with_context(|| format!("failed to load target profile {}", profile_path.display()))? - } else { - bail!( - "no target profile at {}; a target is a TOML file under 'targets/'", - profile_path.display() +) -> Result { + let package = project.default_package(); + let entry = package.entry_source().with_context(|| { + format!( + "package '{}' is a library with no build.entry; a facility plan needs an exact main workflow", + package.manifest.package.name ) - }; - + })?; + let inventory = package_inventory_snapshot(package)?.with_context(|| { + format!( + "package '{}' has no inventory.document; facility planning consumes a validated SBOLInventory document", + package.manifest.package.name + ) + })?; let program_packages = project.program_packages(); let modules = compiled .modules @@ -300,122 +360,181 @@ fn build_for_target( .filter(|module| program_packages.contains(&module.package)) .map(|module| &module.module) .collect::>(); - let lair = PortableLairProgram::lower_program(&modules) - .context("failed to lower the program for a target build")?; - let protocol = lair - .select_protocol() - .context("failed to select a concrete protocol for a target build")?; - - let package = project.default_package(); - let declared = &package.manifest.inventory; - let inventory = BuildInventory { - available_materials: declared.materials.clone(), - available_artifacts: declared.artifacts.clone(), + let requirements = CapabilityRequirements::extract(&modules) + .context("failed to derive workflow capability requirements")?; + let instances = requirements + .instantiate_reachable(&modules, &entry.module, "main") + .context("failed to instantiate reachable workflow capability requirements")?; + let adapter_bindings = crate::adapters::resolve_package_bindings(package, &inventory)?; + let allocation = FacilityAllocation::allocate( + &requirements, + &instances, + &inventory, + adapter_bindings.as_ref(), + ) + .context("failed to allocate reachable requirements across the selected facility")?; + fs::create_dir_all(output_root) + .with_context(|| format!("failed to create {}", output_root.display()))?; + reset_facility_bundle_directories(output_root)?; + + let lowered = crate::facility_lowering::lower_allocated_adapters( + package, + &modules, + &inventory, + &allocation, + adapter_bindings.as_ref(), + output_root, + )?; + let inventory_document = staged_inventory_name(&inventory)?; + let reviewed_lowerings = reviewed_lowering_bundles(&lowered.manifest) + .context("failed to freeze allocated adapter lowerings into the reviewed plan")?; + let mut execution_plan = build_execution_plan( + &allocation, + ExecutionPlanOptions { + inventory_document: inventory_document.clone(), + ..ExecutionPlanOptions::default() + }, + ) + .context("failed to construct the reviewed execution plan")?; + stage_execution_inputs(package, &inventory, &mut execution_plan, output_root)?; + execution_plan.lowerings = reviewed_lowerings; + execution_plan + .validate() + .map_err(|message| anyhow::anyhow!("reviewed execution plan is invalid: {message}"))?; + let requirements_path = output_root.join("capability_requirements.json"); + let instances_path = output_root.join("capability_instances.json"); + let allocation_path = output_root.join("facility_allocation.json"); + let lowering_path = output_root.join("facility_lowering.json"); + let execution_plan_path = output_root.join(EXECUTION_PLAN_FILE); + write_pretty_json(&requirements_path, &requirements)?; + write_pretty_json(&instances_path, &instances)?; + write_pretty_json(&allocation_path, &allocation)?; + write_pretty_json(&lowering_path, &lowered.manifest)?; + write_pretty_json(&execution_plan_path, &execution_plan)?; + let adapter_bindings_path = if let Some(bindings) = adapter_bindings.as_ref() { + let path = output_root.join("adapter_bindings.json"); + write_pretty_json(&path, bindings)?; + Some(path) + } else { + None }; - let artifacts = match &profile { - TargetProfile::Ot2(profile) => { - lab_compiler::backend::opentrons::ot2::compile_dependency_build( - &protocol, profile, &inventory, - ) - .with_context(|| format!("failed to compile the {target} build"))? - .artifacts() - .clone() - } - TargetProfile::Flex(profile) => { - lab_compiler::backend::opentrons::flex::compile_dependency_build( - &protocol, profile, &inventory, - ) - .with_context(|| format!("failed to compile the {target} build"))? - .artifacts() - .clone() - } - TargetProfile::Star(profile) => { - lab_compiler::backend::hamilton::star::compile_dependency_build( - &protocol, profile, &inventory, - ) - .with_context(|| format!("failed to compile the {target} build"))? - .artifacts() - .clone() - } - TargetProfile::Workcell(profile) => { - let station = profile.liquid_handler(); - let station_profile = station - .profile - .as_deref() - .expect("workcell validation requires the liquid handler to name a profile"); - let station_path = project_root - .join("targets") - .join(format!("{station_profile}.toml")); - let station_contents = fs::read_to_string(&station_path).with_context(|| { - format!( - "station '{}' names profile '{station_profile}', but there is no target profile at {}", - station.name, - station_path.display() - ) - })?; - let star_profile = StarTargetProfile::parse(station_profile, &station_contents) - .with_context(|| { - format!("failed to load station profile {}", station_path.display()) - })?; - lab_compiler::backend::workcell::compile_dependency_build( - &protocol, - profile, - &star_profile, - &inventory, - ) - .with_context(|| format!("failed to compile the {target} build"))? - .artifacts() - .clone() - } - }; - let target_root = output_root.join(target); - let mut protocols = Vec::new(); - let mut typst_sources = Vec::new(); - for artifact in artifacts.iter() { - let path = target_root.join(artifact.path()); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; + let bundles = lowered + .manifest + .routes + .iter() + .map(|route| output_root.join(&route.output)) + .collect(); + Ok(PlanCompleted { + package: package.manifest.package.name.clone(), + version: package.manifest.package.version.clone(), + output: output_root.to_path_buf(), + facility: allocation.facility.clone(), + allocated_requirements: allocation.allocations.len(), + adapter_lowerings: lowered.manifest.routes.len(), + requirements: requirements_path, + instances: instances_path, + adapter_bindings: adapter_bindings_path, + allocation: allocation_path, + lowering: lowering_path, + execution_plan: execution_plan_path, + bundles, + protocols: lowered.protocols, + documents: lowered.documents, + }) +} + +fn append_facility_artifacts(human: &mut String, planned: &PlanCompleted) { + if !planned.bundles.is_empty() { + human.push_str("\n\nAsset bundles:"); + for bundle in &planned.bundles { + human.push_str(&format!("\n {}", human_path(bundle))); } - fs::write(&path, artifact.contents()) - .with_context(|| format!("failed to write {}", path.display()))?; - if is_automation_protocol(&path) { - protocols.push(path); + } + if !planned.protocols.is_empty() { + human.push_str("\n\nAutomation protocols:"); + for protocol in &planned.protocols { + human.push_str(&format!("\n {}", human_path(protocol))); } - if artifact.media_type() == "text/x-typst" && is_typeset_document(artifact.path()) { - typst_sources.push(artifact.path().to_owned()); + } + if !planned.documents.is_empty() { + human.push_str("\n\nDocuments:"); + for document in &planned.documents { + human.push_str(&format!("\n {}", human_path(document))); } } - protocols.sort(); - typst_sources.sort(); - - // Typeset every emitted document to a PDF beside its source. A failure - // here is a bug in the emitters — the sources are generated — so the - // build stops rather than shipping a package with missing documents. - let mut documents = Vec::new(); - let typesetter = crate::typeset::Typesetter::new(); - for source in &typst_sources { - let pdf_bytes = typesetter - .compile_pdf(&target_root, source) - .with_context(|| format!("failed to typeset {source}"))?; - let pdf_path = target_root.join(source).with_extension("pdf"); - fs::write(&pdf_path, pdf_bytes) - .with_context(|| format!("failed to write {}", pdf_path.display()))?; - documents.push(pdf_path); +} + +fn human_path(path: &Path) -> String { + let displayed = std::env::current_dir() + .ok() + .and_then(|current| path.strip_prefix(current).ok().map(Path::to_path_buf)) + .unwrap_or_else(|| path.to_path_buf()); + if displayed.as_os_str().is_empty() { + ".".to_owned() + } else { + displayed.display().to_string() } +} - Ok(TargetBuild { - directory: target_root, - protocols, - documents, - }) +/// Replace only compiler-owned adapter bundle directories. The legacy +/// `lowerings/` path is removed during migration so a successful rebuild never +/// leaves an obsolete protocol beside the reviewed `assets/` bundle. +fn reset_facility_bundle_directories(output_root: &Path) -> Result<()> { + for name in ["assets", "lowerings"] { + let path = output_root.join(name); + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(error).with_context(|| format!("failed to inspect {}", path.display())); + } + }; + if !metadata.is_dir() { + bail!( + "refusing to replace managed facility output {} because it is not a directory", + path.display() + ); + } + fs::remove_dir_all(&path) + .with_context(|| format!("failed to replace {}", path.display()))?; + } + Ok(()) } -/// A `text/x-typst` artifact is a complete document unless it is the shared -/// style sheet the documents import. -fn is_typeset_document(path: &str) -> bool { - !path.ends_with("lab-style.typ") +fn build_facility_index(planned: &PlanCompleted, output_root: &Path) -> Result { + let relative = |path: &Path| { + path.strip_prefix(output_root) + .map(Path::to_path_buf) + .with_context(|| { + format!( + "facility artifact {} is outside build output {}", + path.display(), + output_root.display() + ) + }) + }; + Ok(BuildFacilityIndex { + facility: planned.facility.clone(), + allocation: relative(&planned.allocation)?, + lowering: relative(&planned.lowering)?, + execution_plan: relative(&planned.execution_plan)?, + bundles: planned + .bundles + .iter() + .map(|path| relative(path)) + .collect::>>()?, + protocols: planned + .protocols + .iter() + .map(|path| relative(path)) + .collect::>>()?, + documents: planned + .documents + .iter() + .map(|path| relative(path)) + .collect::>>()?, + }) } pub(crate) fn metadata(path: PathBuf, output: &Output) -> Result<()> { @@ -451,6 +570,123 @@ fn load_package(path: &Path) -> Result { .with_context(|| format!("failed to load package from {}", path.display())) } +fn validate_project_inventories(project: &LabProject) -> Result<()> { + for package in project.member_packages() { + let Some(snapshot) = package_inventory_snapshot(package)? else { + continue; + }; + crate::adapters::resolve_package_bindings(package, &snapshot)?; + } + Ok(()) +} + +fn package_inventory_snapshot(package: &LabPackage) -> Result> { + let inventory = &package.manifest.inventory; + let Some(document) = inventory.document.as_ref() else { + return Ok(None); + }; + InventorySnapshot::load(&package.root, document, inventory.facility.as_deref()) + .map(Some) + .with_context(|| { + format!( + "failed to load inventory for package '{}'", + package.manifest.package.name + ) + }) +} + +fn staged_inventory_name(inventory: &InventorySnapshot) -> Result { + let extension = inventory + .source_path() + .extension() + .and_then(|extension| extension.to_str()) + .context("the inventory document needs a UTF-8 file extension")?; + Ok(format!("inventory-source.{extension}")) +} + +/// Copies every mutable package input named by a reviewed plan into its artifact directory. +/// The resulting paths and digests are therefore sufficient for runtime preflight and provenance. +fn stage_execution_inputs( + package: &LabPackage, + inventory: &InventorySnapshot, + plan: &mut ExecutionPlanDocument, + output_root: &Path, +) -> Result<()> { + let inventory_bytes = fs::read(inventory.source_path()).with_context(|| { + format!( + "failed to re-read inventory source {}", + inventory.source_path().display() + ) + })?; + let observed_inventory_hash = sha256_hex(&inventory_bytes); + if observed_inventory_hash != inventory.source_sha256() { + bail!( + "inventory source {} changed after validation; run `lab plan` again from a stable source", + inventory.source_path().display() + ); + } + let inventory_path = output_root.join(&plan.inventory.document); + fs::write(&inventory_path, inventory_bytes) + .with_context(|| format!("failed to stage {}", inventory_path.display()))?; + + let canonical_root = fs::canonicalize(&package.root) + .with_context(|| format!("failed to resolve package root {}", package.root.display()))?; + let adapters_directory = output_root.join("adapters"); + for requirement in &mut plan.requirements { + let Some(adapter) = requirement.adapter.as_mut() else { + continue; + }; + let source = + fs::canonicalize(canonical_root.join(&adapter.profile_path)).with_context(|| { + format!( + "failed to resolve adapter profile {} for '{}'", + adapter.profile_path, requirement.requirement_instance + ) + })?; + if !source.starts_with(&canonical_root) { + bail!( + "adapter profile '{}' for '{}' resolves outside package '{}'", + adapter.profile_path, + requirement.requirement_instance, + package.manifest.package.name + ); + } + let profile = crate::adapters::load_and_validate(&adapter.driver, &source)?; + if profile.sha256 != adapter.profile_sha256 { + bail!( + "adapter profile {} changed after allocation for '{}'", + source.display(), + requirement.requirement_instance + ); + } + fs::create_dir_all(&adapters_directory) + .with_context(|| format!("failed to create {}", adapters_directory.display()))?; + let relative = crate::facility_lowering::staged_adapter_profile_path( + &adapter.driver, + &adapter.profile_sha256, + ); + let destination = output_root.join(&relative); + fs::write(&destination, profile.canonical_toml.as_bytes()) + .with_context(|| format!("failed to stage {}", destination.display()))?; + if sha256_hex(profile.canonical_toml.as_bytes()) != adapter.profile_sha256 { + bail!( + "canonical adapter profile for '{}' does not match its frozen digest", + requirement.requirement_instance + ); + } + adapter.profile_path = relative.to_string_lossy().into_owned(); + } + plan.validate() + .map_err(|message| anyhow::anyhow!("staged execution plan is invalid: {message}")) +} + +fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + fn validate_package_name(name: &str) -> Result<()> { let manifest = format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n"); let parsed = PackageManifest::parse(&manifest)?; @@ -477,6 +713,12 @@ fn write_new(path: &Path, contents: &str) -> Result<()> { fs::write(path, contents).with_context(|| format!("failed to write {}", path.display())) } +fn write_pretty_json(path: &Path, value: &impl Serialize) -> Result<()> { + let mut json = serde_json::to_string_pretty(value)?; + json.push('\n'); + fs::write(path, json).with_context(|| format!("failed to write {}", path.display())) +} + #[derive(Serialize)] struct ProjectCreated { package: String, @@ -503,8 +745,35 @@ struct BuildCompleted { version: String, modules: usize, output: PathBuf, - target: Option, - target_output: Option, + products: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + facility: Option, +} + +#[derive(Serialize)] +struct BuildProduct { + package: String, + module: String, + kind: String, + name: String, +} + +#[derive(Serialize)] +struct PlanCompleted { + package: String, + version: String, + output: PathBuf, + facility: String, + allocated_requirements: usize, + adapter_lowerings: usize, + requirements: PathBuf, + instances: PathBuf, + #[serde(skip_serializing_if = "Option::is_none")] + adapter_bindings: Option, + allocation: PathBuf, + lowering: PathBuf, + execution_plan: PathBuf, + bundles: Vec, protocols: Vec, documents: Vec, } @@ -531,6 +800,24 @@ struct BuildIndex { entry: Option, members: Vec, modules: Vec, + capability_requirements: PathBuf, + #[serde(skip_serializing_if = "Option::is_none")] + capability_instances: Option, + #[serde(skip_serializing_if = "Option::is_none")] + adapter_bindings: Option, + #[serde(skip_serializing_if = "Option::is_none")] + facility: Option, +} + +#[derive(Serialize)] +struct BuildFacilityIndex { + facility: String, + allocation: PathBuf, + lowering: PathBuf, + execution_plan: PathBuf, + bundles: Vec, + protocols: Vec, + documents: Vec, } #[derive(Serialize)] diff --git a/crates/lab-cli/src/execution_run.rs b/crates/lab-cli/src/execution_run.rs new file mode 100644 index 0000000..9786368 --- /dev/null +++ b/crates/lab-cli/src/execution_run.rs @@ -0,0 +1,380 @@ +//! Terminal presentation and live-executor construction for reviewed facility plans. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::net::SocketAddr; +use std::path::PathBuf; + +use anyhow::{Context, Result, bail}; +use lab_compiler::backend::{adapter_catalog, hamilton::star::StarAdapterProfile}; +use lab_runfmt::{STAR_RUN_FORMAT, THERMOCYCLE_RUN_FORMAT}; +use lab_runtime::clock::WallClock; +use lab_runtime::device_executors::{ + HamiltonStarExecutor, OdtcExecutor, ReviewedDocumentSimulationExecutor, +}; +use lab_runtime::events::{EventSink, ProgramExtent, RunEvent}; +use lab_runtime::execution::{ + ExecutionOutcome, ExecutionRunConfig, ExecutorRegistry, LoadedExecutionAction, + load_execution_directory, render_execution_dry_run, run_execution_plan, +}; +use lab_runtime::mode::ExecutionMode; +use lab_runtime::operator::StdinOperator; +use lab_runtime::provenance::{inventory_result_file, write_inventory_result}; + +use crate::Output; + +pub(crate) fn run_execution_command( + directory: PathBuf, + dry_run: bool, + simulate: bool, + yes: bool, + resume: bool, + asset_endpoints: Vec, + output: &Output, +) -> Result<()> { + let loaded = load_execution_directory(&directory)?; + if dry_run { + return output.success( + "dry-run", + serde_json::json!({ + "format": loaded.plan.format, + "plan_sha256": loaded.plan_sha256, + "facility": loaded.plan.inventory.facility, + "nodes": loaded.nodes.len(), + "simulatable": loaded.is_ready(ExecutionMode::Simulation), + "simulation_readiness_issues": loaded.readiness_issues(ExecutionMode::Simulation), + "executable": loaded.is_ready(ExecutionMode::Live), + "execution_readiness_issues": loaded.readiness_issues(ExecutionMode::Live), + }), + render_execution_dry_run(&loaded), + ); + } + + let mode = if simulate { + if !asset_endpoints.is_empty() { + bail!("--asset-endpoint is only meaningful for live execution"); + } + ExecutionMode::Simulation + } else { + ExecutionMode::Live + }; + let mut registry = match mode { + ExecutionMode::Simulation => build_simulation_registry(&loaded)?, + ExecutionMode::Live => { + let addresses = parse_asset_endpoints(&asset_endpoints)?; + build_hardware_registry(&loaded, &addresses)? + } + }; + let mut operator = StdinOperator; + let mut events = HumanSink; + match run_execution_plan( + &loaded, + ExecutionRunConfig { + assume_yes: yes, + resume, + mode, + }, + &mut registry, + &mut operator, + &mut events, + &WallClock, + )? { + ExecutionOutcome::Completed { + executed, + skipped, + started_at_unix_seconds, + ended_at_unix_seconds, + } => { + let existing = loaded.directory.join(inventory_result_file(mode)); + let result = if executed == 0 && existing.is_file() { + None + } else { + Some(write_inventory_result( + &loaded, + mode, + started_at_unix_seconds, + ended_at_unix_seconds, + )?) + }; + let result_path = result + .as_ref() + .map_or(existing.as_path(), |result| result.path.as_path()); + output.success( + mode.as_str(), + serde_json::json!({ + "mode": mode.as_str(), + "plan_sha256": loaded.plan_sha256, + "executed": executed, + "skipped": skipped, + "inventory_result": result_path, + "activity": result.as_ref().map(|result| result.activity.as_str()), + "output_materials": result.as_ref().map(|result| &result.output_materials), + }), + format!( + "Completed reviewed facility {}: {executed} node(s) executed, {skipped} skipped\n Inventory result: {}", + mode.as_str(), + result_path.display() + ), + ) + } + ExecutionOutcome::Cancelled => bail!("run cancelled before any motion"), + ExecutionOutcome::Declined { node } => bail!( + "node '{node}' stopped because the operator declined; resolve the facility and continue the same reviewed plan with --resume" + ), + ExecutionOutcome::Failed { node, error } => bail!( + "node '{node}' failed: {error}; resolve the facility and continue the same reviewed plan with --resume" + ), + } +} + +fn build_simulation_registry( + loaded: &lab_runtime::execution::LoadedExecutionPlan, +) -> Result { + let catalog = adapter_catalog().context("failed to load the compiler adapter catalog")?; + let descriptors = catalog + .adapters + .iter() + .map(|descriptor| (descriptor.id.as_str(), descriptor)) + .collect::>(); + let mut keys = BTreeSet::new(); + let mut registry = ExecutorRegistry::new(); + for node in &loaded.nodes { + let LoadedExecutionAction::Execute { + requirement, + document: Some(document), + } = &node.action + else { + continue; + }; + let adapter = requirement + .adapter + .as_ref() + .context("simulation requires a frozen adapter binding")?; + let descriptor = descriptors.get(adapter.driver.as_str()).with_context(|| { + format!( + "adapter '{}' is not present in this compiler build", + adapter.driver + ) + })?; + if !descriptor.services.simulation { + bail!("adapter '{}' does not provide simulation", adapter.driver); + } + if !descriptor + .capabilities + .contains(&requirement.capability_kind) + { + bail!( + "adapter '{}' does not simulate capability '{}'", + adapter.driver, + requirement.capability_kind + ); + } + if !descriptor.control_modes.contains(&requirement.control_mode) { + bail!( + "adapter '{}' does not accept control mode '{}'", + adapter.driver, + requirement.control_mode + ); + } + if !descriptor.accepted_run_formats.contains(document.format()) { + bail!( + "adapter '{}' does not simulate reviewed format '{}'", + adapter.driver, + document.format() + ); + } + let key = ( + requirement.asset.clone(), + adapter.driver.clone(), + document.format().to_owned(), + ); + if keys.insert(key.clone()) { + registry.register( + key.0, + key.1, + key.2, + Box::::default(), + )?; + } + } + Ok(registry) +} + +fn parse_asset_endpoints(entries: &[String]) -> Result> { + let mut addresses = BTreeMap::new(); + for entry in entries { + let Some((asset, address)) = entry.split_once('=') else { + bail!("--asset-endpoint takes ASSET_IRI=ADDRESS for a facility execution plan"); + }; + let address = address.parse().with_context(|| { + format!("'{address}' is not an address for Asset '{asset}'") + })?; + if addresses.insert(asset.to_owned(), address).is_some() { + bail!("Asset '{asset}' has more than one --asset-endpoint address"); + } + } + Ok(addresses) +} + +fn build_hardware_registry( + loaded: &lab_runtime::execution::LoadedExecutionPlan, + addresses: &BTreeMap, +) -> Result { + let mut bindings = BTreeMap::<(String, String, String), (String, String)>::new(); + for node in &loaded.nodes { + let LoadedExecutionAction::Execute { + requirement, + document: Some(document), + } = &node.action + else { + continue; + }; + let Some(adapter) = &requirement.adapter else { + continue; + }; + let key = ( + requirement.asset.clone(), + adapter.driver.clone(), + document.format().to_owned(), + ); + let profile = (adapter.profile_path.clone(), adapter.profile_sha256.clone()); + if let Some(prior) = bindings.insert(key.clone(), profile.clone()) + && prior != profile + { + bail!( + "asset '{}' uses adapter '{}' and format '{}' with two different frozen profiles", + key.0, + key.1, + key.2 + ); + } + } + + let star_assets = bindings + .keys() + .filter(|(_, driver, _)| driver == "hamilton.star") + .map(|(asset, _, _)| asset) + .collect::>(); + if star_assets.len() > 1 { + bail!( + "this runtime can address only one Hamilton STAR over USB, but the reviewed plan binds {}", + star_assets + .iter() + .copied() + .cloned() + .collect::>() + .join(", ") + ); + } + + let mut used_addresses = BTreeSet::new(); + let mut registry = ExecutorRegistry::new(); + for ((asset, driver, format), (profile_path, _profile_sha256)) in bindings { + match (driver.as_str(), format.as_str()) { + ("hamilton.star", STAR_RUN_FORMAT) => { + let path = loaded.directory.join(&profile_path); + let text = fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + let name = path + .file_stem() + .and_then(|name| name.to_str()) + .context("a STAR adapter profile needs a UTF-8 file name")?; + let profile = StarAdapterProfile::parse(name, &text).with_context(|| { + format!("failed to parse frozen profile {}", path.display()) + })?; + registry.register( + &asset, + &driver, + &format, + Box::new(HamiltonStarExecutor::new( + &asset, + profile.run.autoload_park_track, + )), + )?; + } + ("inheco.odtc", THERMOCYCLE_RUN_FORMAT) => { + let address = addresses.get(&asset).with_context(|| { + format!( + "Inheco ODTC Asset '{asset}' has no runtime address; pass --asset-endpoint '{asset}='" + ) + })?; + used_addresses.insert(asset.clone()); + registry.register( + &asset, + &driver, + &format, + Box::new(OdtcExecutor::new(&asset, *address)), + )?; + } + _ => bail!( + "this Lab runtime has no live executor for asset '{asset}', adapter '{driver}', format '{format}'" + ), + } + } + if let Some(unused) = addresses + .keys() + .find(|asset| !used_addresses.contains(*asset)) + { + bail!( + "--asset-endpoint supplies an address for Asset '{unused}', which the reviewed plan does not use as a networked executor" + ); + } + Ok(registry) +} + +struct HumanSink; + +impl EventSink for HumanSink { + fn emit(&mut self, event: RunEvent) { + match event { + RunEvent::Planned { pending, completed } => println!( + "about to execute {pending} facility node(s){}", + if completed == 0 { + String::new() + } else { + format!(", resuming past {completed} completed") + } + ), + RunEvent::Connecting { asset, detail } => { + println!("connecting to Asset {asset} ({detail})") + } + RunEvent::Connected { asset } => println!("connected; Asset {asset} is ready"), + RunEvent::NodeSkipped { id } => println!("skipping {id} (completed in the ledger)"), + RunEvent::NodeStarted { .. } | RunEvent::NodeCompleted { .. } => {} + RunEvent::DocumentStarted { + asset, + driver, + format, + title, + } => println!("\n{title}\n Asset: {asset}\n Adapter: {driver}\n Document: {format}"), + RunEvent::ProgramStarted { + asset, + title, + extent, + } => match extent { + ProgramExtent::Frames { frames } => { + println!("\n{asset}: {title} ({frames} frames)") + } + ProgramExtent::Plateaus { plateaus, .. } => { + println!("\n{asset}: {title} ({plateaus} plateaus)") + } + }, + RunEvent::Frame { + index, description, .. + } => println!(" [{index:>3}] {description}"), + RunEvent::ThermalRunning { .. } => { + println!("running; resume uses the exact reviewed plan if interrupted") + } + RunEvent::ThermalWarning { asset, warning } => { + println!("{asset} warning: {warning}") + } + RunEvent::ThermalHold { celsius, .. } => { + println!("holding the block at {celsius} C until retrieval") + } + RunEvent::DoorOpened { asset } => println!("{asset} door is open"), + RunEvent::DoorClosed { asset } => println!("{asset} door is closed"), + RunEvent::AttentionRequired { prompt, .. } => println!("\nby hand: {prompt}"), + RunEvent::AttentionReleased { .. } | RunEvent::LabwareMoved { .. } => {} + } + } +} diff --git a/crates/lab-cli/src/facility_lowering.rs b/crates/lab-cli/src/facility_lowering.rs new file mode 100644 index 0000000..081ba0a --- /dev/null +++ b/crates/lab-cli/src/facility_lowering.rs @@ -0,0 +1,413 @@ +//! Facility-derived adapter lowering and immutable artifact staging. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use lab_compiler::backend::{adapter_catalog, lower_dependency_build_with_adapter}; +use lab_compiler::planning::{ + AdapterBindingSnapshot, BuildInventory, FACILITY_LOWERING_SCHEMA_VERSION, FacilityAllocation, + FacilityLoweredArtifact, FacilityLoweredArtifactRole, FacilityLoweredRequirement, + FacilityLoweringManifest, FacilityLoweringRoute, +}; +use lab_compiler::{ArtifactBundle, CheckedModule, PortableLairProgram}; +use lab_inventory::InventorySnapshot; +use lab_package::LabPackage; +use sha2::{Digest, Sha256}; + +pub(crate) struct FacilityLoweringOutput { + pub(crate) manifest: FacilityLoweringManifest, + pub(crate) protocols: Vec, + pub(crate) documents: Vec, +} + +/// Derives concrete backend invocations from exact facility allocations. +/// +/// A package never selects a device implementation here. Each route exists only because a reachable semantic +/// requirement was allocated to an offering, that offering belongs to an exact Asset, and the +/// Asset has an explicit local adapter binding whose implementation provides lowering. +pub(crate) fn lower_allocated_adapters( + package: &LabPackage, + modules: &[&CheckedModule], + inventory: &InventorySnapshot, + allocation: &FacilityAllocation, + bindings: Option<&AdapterBindingSnapshot>, + output_root: &Path, +) -> Result { + let catalog = adapter_catalog().context("failed to load the compiler adapter catalog")?; + let descriptors = catalog + .adapters + .iter() + .map(|descriptor| (descriptor.id.as_str(), descriptor)) + .collect::>(); + let mut grouped = + BTreeMap::<(String, String, PathBuf, String), Vec>::new(); + for selected in &allocation.allocations { + let Some(adapter) = selected.adapter.as_ref() else { + continue; + }; + grouped + .entry(( + selected.asset.clone(), + adapter.driver.clone(), + adapter.profile_path.clone(), + adapter.profile_sha256.clone(), + )) + .or_default() + .push(FacilityLoweredRequirement { + requirement_instance: selected.requirement_instance.clone(), + capability_kind: selected.capability_kind.clone(), + offering: selected.offering.clone(), + }); + } + + if let Some(bindings) = bindings + && (bindings.inventory_sha256 != allocation.inventory_sha256 + || bindings.facility != allocation.facility) + { + bail!( + "adapter bindings and facility allocation do not describe the same inventory snapshot" + ); + } + + let mut lowerable = Vec::new(); + for (key, mut requirements) in grouped { + let descriptor = descriptors.get(key.1.as_str()).with_context(|| { + format!( + "allocated adapter '{}' is not present in this compiler build", + key.1 + ) + })?; + if !descriptor.services.lowering { + continue; + } + let mut emitted_formats = descriptor.emitted_run_formats.iter(); + let automation_format = emitted_formats.next().cloned().with_context(|| { + format!( + "adapter '{}' provides lowering but declares no emitted run-document format", + descriptor.id + ) + })?; + if emitted_formats.next().is_some() { + bail!( + "adapter '{}' provides whole-program lowering with several emitted run-document formats; the lowering API must identify each artifact format explicitly", + descriptor.id + ); + } + requirements + .sort_by(|left, right| left.requirement_instance.cmp(&right.requirement_instance)); + lowerable.push((key, requirements, automation_format)); + } + if lowerable.len() > 1 { + let routes = lowerable + .iter() + .map(|((asset, driver, _, _), _, _)| format!("{asset} through {driver}")) + .collect::>() + .join(", "); + bail!( + "facility allocation selects several whole-program lowerers ({routes}); these legacy backends cannot yet partition one program by requirement" + ); + } + let mut lowering_directories = facility_lowering_directories( + lowerable + .iter() + .map(|((asset, driver, _, _), _, _)| (asset.as_str(), driver.as_str())), + ); + + let mut routes = Vec::new(); + let mut protocols = Vec::new(); + let mut documents = Vec::new(); + if !lowerable.is_empty() { + let lair = PortableLairProgram::lower_program(modules) + .context("failed to lower the allocated program for facility adapters")?; + let protocol = lair + .select_protocol() + .context("failed to select a concrete protocol for facility adapter lowering")?; + let build_inventory = semantic_build_inventory(modules, inventory)?; + + for ( + (asset, driver, source_profile_path, profile_sha256), + requirements, + automation_format, + ) in lowerable + { + let source = package.root.join(&source_profile_path); + let profile = + crate::adapters::load_and_validate(&driver, &source).with_context(|| { + format!( + "failed to load operational profile for Asset '{}' adapter '{}'", + asset, driver + ) + })?; + if profile.sha256 != profile_sha256 { + bail!( + "operational profile {} changed after adapter allocation for Asset '{}'", + source.display(), + asset + ); + } + let bundle = lower_dependency_build_with_adapter( + &driver, + &profile.name, + &profile.canonical_toml, + &protocol, + &build_inventory, + ) + .with_context(|| { + format!( + "failed to lower the allocated program for Asset '{}' through adapter '{}'", + asset, driver + ) + })?; + let relative_output = lowering_directories + .remove(&(asset.clone(), driver.clone())) + .expect("every lowerable Asset and adapter has an output directory"); + let written = write_facility_artifacts( + &bundle, + output_root, + &relative_output, + &automation_format, + &mut protocols, + &mut documents, + )?; + routes.push(FacilityLoweringRoute { + id: facility_lowering_id(&asset, &driver), + asset, + driver: driver.clone(), + profile_path: staged_adapter_profile_path(&driver, &profile_sha256), + profile_sha256, + requirements, + output: relative_output, + artifacts: written, + }); + } + } + routes.sort_by(|left, right| (&left.asset, &left.driver).cmp(&(&right.asset, &right.driver))); + protocols.sort(); + documents.sort(); + Ok(FacilityLoweringOutput { + manifest: FacilityLoweringManifest { + schema_version: FACILITY_LOWERING_SCHEMA_VERSION.to_owned(), + inventory_sha256: allocation.inventory_sha256.clone(), + facility: allocation.facility.clone(), + routes, + }, + protocols, + documents, + }) +} + +fn semantic_build_inventory( + modules: &[&CheckedModule], + snapshot: &InventorySnapshot, +) -> Result { + let material_lots = snapshot + .active_material_lots() + .context("failed to index active SBOLInventory MaterialLots")?; + let lots_by_component = material_lots + .components() + .map(|(component, lots)| { + ( + component.as_str().to_owned(), + lots.iter().map(|lot| lot.as_str().to_owned()).collect(), + ) + }) + .collect::>(); + BuildInventory::from_material_lots( + modules, + snapshot.source_sha256(), + snapshot.facility().as_str(), + &lots_by_component, + ) + .context("failed to bind checked designs to SBOLInventory MaterialLots") +} + +fn facility_lowering_directories<'a>( + routes: impl IntoIterator, +) -> BTreeMap<(String, String), PathBuf> { + let routes = routes + .into_iter() + .map(|(asset, driver)| (asset, driver, facility_asset_name(asset))) + .collect::>(); + let mut name_counts = BTreeMap::::new(); + for (_, _, name) in &routes { + *name_counts.entry(name.clone()).or_default() += 1; + } + routes + .into_iter() + .map(|(asset, driver, name)| { + let directory = if name_counts[&name] == 1 { + name + } else { + let identity = format!("{asset}\0{driver}"); + format!("{name}-{}", &sha256_hex(identity.as_bytes())[..8]) + }; + ( + (asset.to_owned(), driver.to_owned()), + PathBuf::from("assets").join(directory), + ) + }) + .collect() +} + +fn facility_asset_name(asset: &str) -> String { + let raw_name = asset + .rsplit(['/', '#']) + .find(|segment| !segment.is_empty()) + .unwrap_or("asset"); + let mut name = String::new(); + for character in raw_name.chars() { + if character.is_ascii_alphanumeric() { + name.push(character.to_ascii_lowercase()); + } else if matches!(character, '-' | '_') { + name.push(character); + } else if !name.ends_with('-') { + name.push('-'); + } + } + let name = name.trim_matches('-'); + if name.is_empty() { + "asset".to_owned() + } else { + name.to_owned() + } +} + +fn facility_lowering_id(asset: &str, driver: &str) -> String { + let asset_hash = sha256_hex(asset.as_bytes()); + format!("{}-{}", driver.replace('.', "-"), &asset_hash[..12]) +} + +fn write_facility_artifacts( + bundle: &ArtifactBundle, + output_root: &Path, + relative_output: &Path, + automation_format: &str, + protocols: &mut Vec, + documents: &mut Vec, +) -> Result> { + let route_root = output_root.join(relative_output); + let mut artifacts = Vec::new(); + let mut typst_sources = Vec::new(); + for artifact in bundle.iter() { + let relative_path = PathBuf::from(artifact.path()); + let path = route_root.join(&relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + fs::write(&path, artifact.contents()) + .with_context(|| format!("failed to write {}", path.display()))?; + let role = if is_automation_protocol(&path) { + protocols.push(path); + FacilityLoweredArtifactRole::AutomationProtocol + } else { + FacilityLoweredArtifactRole::Support + }; + if artifact.media_type() == "text/x-typst" && is_typeset_document(artifact.path()) { + typst_sources.push(relative_path.clone()); + } + artifacts.push(FacilityLoweredArtifact { + path: relative_path, + media_type: artifact.media_type().to_owned(), + sha256: sha256_hex(artifact.contents()), + role, + format: (role == FacilityLoweredArtifactRole::AutomationProtocol) + .then(|| automation_format.to_owned()), + }); + } + + typst_sources.sort(); + let typesetter = crate::typeset::Typesetter::new(); + for source in typst_sources { + let source_text = source + .to_str() + .context("a generated Typst source path must be UTF-8")?; + let pdf_bytes = typesetter + .compile_pdf(&route_root, source_text) + .with_context(|| format!("failed to typeset {}", source.display()))?; + let pdf_relative = source.with_extension("pdf"); + let pdf_path = route_root.join(&pdf_relative); + fs::write(&pdf_path, &pdf_bytes) + .with_context(|| format!("failed to write {}", pdf_path.display()))?; + documents.push(pdf_path); + artifacts.push(FacilityLoweredArtifact { + path: pdf_relative, + media_type: "application/pdf".to_owned(), + sha256: sha256_hex(&pdf_bytes), + role: FacilityLoweredArtifactRole::OperatorDocument, + format: None, + }); + } + artifacts.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(artifacts) +} + +pub(crate) fn staged_adapter_profile_path(driver: &str, profile_sha256: &str) -> PathBuf { + PathBuf::from("adapters").join(format!("{driver}-{}.toml", &profile_sha256[..12])) +} + +fn is_automation_protocol(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.ends_with("_protocol.py") + || name.ends_with("_protocol.json") + || name.ends_with(".star.json") + || name.ends_with(".odtc.json") + || name.ends_with(".read.json") + }) +} + +fn is_typeset_document(path: &str) -> bool { + !path.ends_with("lab-style.typ") +} + +fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_unique_asset_gets_a_short_readable_directory() { + let directories = facility_lowering_directories([( + "https://example.org/facility/Opentrons_OT2", + "opentrons.ot2", + )]); + + assert_eq!( + directories[&( + "https://example.org/facility/Opentrons_OT2".to_owned(), + "opentrons.ot2".to_owned() + )], + PathBuf::from("assets/opentrons_ot2") + ); + } + + #[test] + fn colliding_asset_names_get_only_the_hash_they_need() { + let directories = facility_lowering_directories([ + ("https://example.org/room-a/reader", "reader.alpha"), + ("https://example.org/room-b/reader", "reader.beta"), + ]); + let first = &directories[&( + "https://example.org/room-a/reader".to_owned(), + "reader.alpha".to_owned(), + )]; + let second = &directories[&( + "https://example.org/room-b/reader".to_owned(), + "reader.beta".to_owned(), + )]; + + assert_ne!(first, second); + assert!(first.to_string_lossy().starts_with("assets/reader-")); + assert!(second.to_string_lossy().starts_with("assets/reader-")); + } +} diff --git a/crates/lab-cli/src/main.rs b/crates/lab-cli/src/main.rs index 12eba3c..f2e4db8 100644 --- a/crates/lab-cli/src/main.rs +++ b/crates/lab-cli/src/main.rs @@ -1,9 +1,9 @@ +mod adapters; mod commands; -mod run; -mod targets; +mod execution_run; +mod facility_lowering; mod typeset; mod update; -mod workcell_run; use std::path::PathBuf; @@ -42,8 +42,7 @@ enum Command { #[arg(default_value = ".")] path: PathBuf, }, - /// Build a package into verified portable module artifacts, and into - /// automation protocols when a target is named. + /// Build verified experiment artifacts and specialize through a configured facility. Build { /// Package directory or any path inside a package. #[arg(default_value = ".")] @@ -51,46 +50,42 @@ enum Command { /// Artifact directory, relative to the project root unless absolute. #[arg(long)] out_dir: Option, - /// Target profile to compile for, named by a file under `targets/`; - /// defaults to `[build] target` in the manifest. + }, + /// Write only the reviewed facility plan and its adapter lowerings. + Plan { + /// Package directory or any path inside a package. + #[arg(default_value = ".")] + path: PathBuf, + /// Plan artifact directory, relative to the project root unless absolute. #[arg(long)] - target: Option, - /// Build portable module IR only, ignoring the manifest's default - /// target. - #[arg(long, conflicts_with = "target")] - no_target: bool, + out_dir: Option, }, - /// Discover, validate, and render compiler-owned target profiles. - Targets { + /// Discover, validate, and render asset-bound adapter profiles. + Adapters { #[command(subcommand)] - command: TargetsCommand, + command: AdaptersCommand, }, - /// Execute an emitted run package — a Hamilton STAR package or a - /// workcell wave — on the connected stations, or review it with - /// --dry-run. + /// Execute a reviewed facility plan, or validate and review it with --dry-run. Run { - /// A run directory produced by `lab build` (the target output - /// directory, or one wave directory of a dependency build). A - /// directory holding `plan.workcell.json` runs as a workcell - /// wave; anything else runs as a Hamilton STAR package. + /// A directory containing plan.execution.json. path: PathBuf, /// Validate and print the full step table without touching /// hardware. #[arg(long)] dry_run: bool, + /// Execute through simulation adapters without touching physical hardware. + #[arg(long, conflicts_with = "dry_run")] + simulate: bool, /// Skip the initial confirmation. Handoffs and manual steps still /// require the operator. #[arg(long)] yes: bool, - /// Continue a workcell wave from its ledger, skipping nodes it - /// records as completed. + /// Continue the exact reviewed plan from its durable ledger. #[arg(long)] resume: bool, - /// Where a networked station answers on this bench, as - /// NAME=ADDRESS (repeatable). Compiled artifacts never carry - /// addresses; the bench supplies them at run time. - #[arg(long = "station", value_name = "NAME=ADDRESS")] - station: Vec, + /// Where a networked Asset answers, as ASSET_IRI=ADDRESS (repeatable). + #[arg(long, value_name = "ASSET_IRI=ADDRESS")] + asset_endpoint: Vec, }, /// Print resolved package metadata and source-module names. Metadata { @@ -107,25 +102,24 @@ enum Command { } #[derive(Debug, Subcommand)] -enum TargetsCommand { - /// Describe every backend and station kind, or one backend in detail. +enum AdaptersCommand { + /// Describe every adapter implementation, or one driver in detail. Describe { - /// Concrete `[target] backend` value to describe. + /// Stable adapter ID such as `hamilton.star`. #[arg(long)] - backend: Option, + driver: Option, }, - /// Print the complete reference profile for one backend. + /// Print the complete reference profile for one adapter. Default { - /// Concrete `[target] backend` value. - backend: String, + driver: String, /// Profile filename stem used for validation metadata. - #[arg(long, default_value = "target")] + #[arg(long, default_value = "adapter")] name: String, }, - /// Parse and semantically validate a target profile. - Validate { path: PathBuf }, - /// Validate and print a complete canonical target profile. - Render { path: PathBuf }, + /// Parse and semantically validate an adapter profile against an explicit driver. + Validate { driver: String, path: PathBuf }, + /// Validate and print a complete canonical adapter profile. + Render { driver: String, path: PathBuf }, } struct Output { @@ -169,35 +163,30 @@ fn run() -> Result<()> { match cli.command { Command::New { path, name } => commands::new_project(path, name, &output), Command::Check { path } => commands::check(path, &output), - Command::Build { - path, - out_dir, - target, - no_target, - } => commands::build(path, out_dir, target, no_target, &output), - Command::Targets { command } => match command { - TargetsCommand::Describe { backend } => targets::describe(backend, &output), - TargetsCommand::Default { backend, name } => targets::default(backend, name, &output), - TargetsCommand::Validate { path } => targets::validate(path, &output), - TargetsCommand::Render { path } => targets::render(path, &output), + Command::Build { path, out_dir } => commands::build(path, out_dir, &output), + Command::Plan { path, out_dir } => commands::plan(path, out_dir, &output), + Command::Adapters { command } => match command { + AdaptersCommand::Describe { driver } => adapters::describe(driver, &output), + AdaptersCommand::Default { driver, name } => adapters::default(driver, name, &output), + AdaptersCommand::Validate { driver, path } => adapters::validate(driver, path, &output), + AdaptersCommand::Render { driver, path } => adapters::render(driver, path, &output), }, Command::Run { path, dry_run, + simulate, yes, resume, - station, - } => { - if workcell_run::is_workcell_directory(&path) { - workcell_run::run_workcell_command(path, dry_run, yes, resume, station, &output) - } else if resume || !station.is_empty() { - anyhow::bail!( - "--resume and --station apply to workcell waves; this directory holds a Hamilton STAR package, which re-runs from its documents" - ) - } else { - run::run(path, dry_run, yes, &output) - } - } + asset_endpoint, + } => execution_run::run_execution_command( + path, + dry_run, + simulate, + yes, + resume, + asset_endpoint, + &output, + ), Command::Metadata { path } => commands::metadata(path, &output), Command::Update { check } => update::update(check, &output), } @@ -209,32 +198,67 @@ mod tests { #[test] fn parses_package_build_options() { + let cli = Cli::try_parse_from(["lab", "build", "project", "--out-dir", "dist"]).unwrap(); + assert!(matches!( + cli.command, + Command::Build { path, out_dir } + if path.as_path() == std::path::Path::new("project") + && out_dir.as_deref() == Some(std::path::Path::new("dist")) + )); + } + + #[test] + fn parses_facility_plan_options() { + let cli = Cli::try_parse_from(["lab", "plan", "project", "--out-dir", "review"]).unwrap(); + assert!(matches!( + cli.command, + Command::Plan { path, out_dir } + if path.as_path() == std::path::Path::new("project") + && out_dir.as_deref() == Some(std::path::Path::new("review")) + )); + } + + #[test] + fn parses_exact_asset_endpoints_for_facility_runs() { let cli = Cli::try_parse_from([ "lab", - "build", - "project", - "--out-dir", - "dist", - "--target", - "opentrons-ot2", + "run", + "review", + "--resume", + "--asset-endpoint", + "https://example.org/facility/odtc=192.0.2.1:8080", ]) .unwrap(); assert!(matches!( cli.command, - Command::Build { path, out_dir, target, no_target } - if path.as_path() == std::path::Path::new("project") - && out_dir.as_deref() == Some(std::path::Path::new("dist")) - && target.as_deref() == Some("opentrons-ot2") - && !no_target + Command::Run { + path, + resume: true, + asset_endpoint, + .. + } if path.as_path() == std::path::Path::new("review") + && asset_endpoint == ["https://example.org/facility/odtc=192.0.2.1:8080"] )); } #[test] - fn rejects_naming_a_target_and_opting_out_of_one() { - assert!( - Cli::try_parse_from(["lab", "build", "--target", "opentrons-ot2", "--no-target"]) - .is_err() - ); + fn parses_explicit_facility_simulation_mode() { + let cli = Cli::try_parse_from(["lab", "run", "review", "--simulate", "--resume"]).unwrap(); + assert!(matches!( + cli.command, + Command::Run { + path, + simulate: true, + resume: true, + .. + } if path.as_path() == std::path::Path::new("review") + )); + } + + #[test] + fn rejects_the_removed_target_surfaces() { + assert!(Cli::try_parse_from(["lab", "build", "--target", "opentrons-ot2"]).is_err()); + assert!(Cli::try_parse_from(["lab", "targets", "describe"]).is_err()); } #[test] @@ -244,12 +268,12 @@ mod tests { } #[test] - fn parses_target_contract_commands() { + fn parses_adapter_contract_commands() { let cli = Cli::try_parse_from([ "lab", - "targets", + "adapters", "describe", - "--backend", + "--driver", "hamilton.star", "--json", ]) @@ -257,18 +281,27 @@ mod tests { assert!(cli.json); assert!(matches!( cli.command, - Command::Targets { - command: TargetsCommand::Describe { backend: Some(backend) } - } if backend == "hamilton.star" + Command::Adapters { + command: AdaptersCommand::Describe { + driver: Some(driver) + } + } if driver == "hamilton.star" )); - let cli = Cli::try_parse_from(["lab", "targets", "default", "workcell", "--name", "bench"]) - .unwrap(); + let cli = Cli::try_parse_from([ + "lab", + "adapters", + "validate", + "inheco.odtc", + "adapters/cycler.toml", + ]) + .unwrap(); assert!(matches!( cli.command, - Command::Targets { - command: TargetsCommand::Default { backend, name } - } if backend == "workcell" && name == "bench" + Command::Adapters { + command: AdaptersCommand::Validate { driver, path } + } if driver == "inheco.odtc" + && path.as_path() == std::path::Path::new("adapters/cycler.toml") )); } diff --git a/crates/lab-cli/src/run.rs b/crates/lab-cli/src/run.rs deleted file mode 100644 index 4b45b70..0000000 --- a/crates/lab-cli/src/run.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! The `lab run` command for a Hamilton STAR package: terminal presentation -//! over the runtime's loader and replay loop. - -use std::path::PathBuf; - -use anyhow::{Result, bail}; -use lab_runtime::operator::{ConfirmKind, Operator, StdinOperator}; -use lab_runtime::star::{RunOutcome, execute_runs, load_run_directory, render_dry_run}; - -use crate::Output; - -pub(crate) fn run(directory: PathBuf, dry_run: bool, yes: bool, output: &Output) -> Result<()> { - let (runs, autoload_park_track) = load_run_directory(&directory)?; - let total_steps: usize = runs.iter().map(|run| run.steps.len()).sum(); - - if dry_run { - return output.success( - "dry-run", - serde_json::json!({ - "runs": runs.len(), - "steps": total_steps, - }), - render_dry_run(&runs), - ); - } - - println!( - "about to execute {} run document(s), {} frames, on the first Hamilton STAR on USB", - runs.len(), - total_steps - ); - let mut operator = StdinOperator; - if !yes - && !operator.confirm( - ConfirmKind::PreRun, - "proceed? The machine will move. [y/N] ", - )? - { - bail!("run cancelled before any motion"); - } - - let star = lab_runtime::star::open_usb_star(autoload_park_track)?; - println!("connected; the setup choreography has run"); - - let mut pause = |prompt: &str| { - println!("\nby hand — {prompt}"); - StdinOperator - .confirm( - ConfirmKind::Manual, - "done, and the bench matches the plan? Continue [y/N] ", - ) - .unwrap_or(false) - }; - let mut narrate = |line: &str| println!("{line}"); - match execute_runs(&star, &runs, &mut pause, &mut narrate)? { - RunOutcome::Completed { steps } => output.success( - "run", - serde_json::json!({ "steps": steps }), - format!("completed {steps} machine steps"), - ), - RunOutcome::Aborted { - run_id, - step_index, - error, - } => { - bail!( - "firmware error at {run_id} step {}: {error}; channels were retracted to Z-safety — resolve the bench and re-run from this run document", - step_index + 1 - ) - } - } -} diff --git a/crates/lab-cli/src/targets.rs b/crates/lab-cli/src/targets.rs deleted file mode 100644 index b4583a2..0000000 --- a/crates/lab-cli/src/targets.rs +++ /dev/null @@ -1,124 +0,0 @@ -//! Target-profile discovery and validation commands. - -use std::fs; -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result, bail}; -use lab_compiler::backend::{ - TargetCapabilitiesDocument, TargetCapability, ValidatedTargetProfile, default_target_profile, - target_capabilities, validate_target_profile, -}; - -use crate::Output; - -pub(crate) fn describe(backend: Option, output: &Output) -> Result<()> { - let document = target_capabilities()?; - match backend { - Some(backend) => { - let capability = document - .targets - .into_iter() - .find(|target| target.backend == backend) - .with_context(|| { - format!("this compiler does not provide target backend '{backend}'") - })?; - let human = render_capability(&capability); - output.success("target-described", capability, human) - } - None => { - let human = render_capabilities(&document); - output.success("targets-described", document, human) - } - } -} - -pub(crate) fn default(backend: String, name: String, output: &Output) -> Result<()> { - let profile = default_target_profile(&backend, &name)?; - let human = profile.canonical_toml.clone(); - output.success("target-default", profile, human) -} - -pub(crate) fn validate(path: PathBuf, output: &Output) -> Result<()> { - let profile = load_and_validate(&path)?; - let human = format!( - "Validated {} as {}\n schema: {}\n sha256: {}", - path.display(), - profile.backend, - profile.schema_version, - profile.sha256 - ); - output.success("target-validated", profile, human) -} - -pub(crate) fn render(path: PathBuf, output: &Output) -> Result<()> { - let profile = load_and_validate(&path)?; - let human = profile.canonical_toml.clone(); - output.success("target-rendered", profile, human) -} - -fn load_and_validate(path: &Path) -> Result { - if !path.is_file() { - bail!("no target profile at {}", path.display()); - } - let contents = - fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; - let name = path - .file_stem() - .and_then(|name| name.to_str()) - .context("a target profile file needs a UTF-8 file name")?; - validate_target_profile(name, &contents).map_err(Into::into) -} - -fn render_capabilities(document: &TargetCapabilitiesDocument) -> String { - let mut lines = vec![format!( - "Lab {} target profiles ({})", - document.compiler_version, document.profile_schema_version - )]; - for target in &document.targets { - lines.push(format!(" {:<18} {}", target.backend, target.display_name)); - } - lines.push("\nWorkcell station kinds:".to_string()); - for station in &document.station_kinds { - let execution = match (station.planner_assigns_work, station.runtime_executor) { - (true, true) => "planned and executable", - (true, false) => "planned; no runtime executor", - (false, true) => "executable; no planner assignment", - (false, false) => "declared only", - }; - lines.push(format!( - " {:<24} {} ({execution})", - station.kind, station.display_name - )); - } - lines.join("\n") -} - -fn render_capability(capability: &TargetCapability) -> String { - format!( - "{}\n backend: {}\n capabilities: {}\n\n{}", - capability.display_name, - capability.backend, - capability - .capabilities - .iter() - .map(String::as_str) - .collect::>() - .join(", "), - capability.default_profile.canonical_toml - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn loading_uses_the_file_stem_as_the_profile_name() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("flex-bay.toml"); - fs::write(&path, "[target]\nbackend = \"opentrons.flex\"\n").unwrap(); - let profile = load_and_validate(&path).unwrap(); - assert_eq!(profile.name, "flex-bay"); - assert_eq!(profile.backend, "opentrons.flex"); - } -} diff --git a/crates/lab-cli/src/workcell_run.rs b/crates/lab-cli/src/workcell_run.rs deleted file mode 100644 index 251e2a3..0000000 --- a/crates/lab-cli/src/workcell_run.rs +++ /dev/null @@ -1,132 +0,0 @@ -//! The `lab run` command for a workcell wave: terminal presentation over -//! the runtime's workcell walk. - -use std::path::PathBuf; - -use anyhow::{Result, bail}; -use lab_runtime::clock::WallClock; -use lab_runtime::events::{EventSink, ProgramExtent, RunEvent}; -use lab_runtime::operator::StdinOperator; -use lab_runtime::stations::HardwareConnector; -use lab_runtime::workcell::{ - Bench, RunConfig, WorkcellOutcome, load_workcell_directory, parse_station_addresses, - render_dry_run, run_workcell, -}; - -pub(crate) use lab_runtime::workcell::is_workcell_directory; - -/// The terminal sink: narrates the walk the way an operator at the bench -/// reads it. -struct HumanSink; - -impl EventSink for HumanSink { - fn emit(&mut self, event: RunEvent) { - match event { - RunEvent::Planned { pending, completed } => println!( - "about to execute {pending} coordination node(s){}", - if completed == 0 { - String::new() - } else { - format!(", resuming past {completed} completed") - } - ), - RunEvent::Connecting { station, detail } => { - println!("connecting to {station} ({detail})") - } - RunEvent::Connected { station } => println!("connected; {station} is ready"), - RunEvent::NodeSkipped { id } => println!("skipping {id} (completed in the ledger)"), - RunEvent::NodeStarted { .. } | RunEvent::NodeCompleted { .. } => {} - RunEvent::ProgramStarted { - station, - title, - extent, - } => match extent { - ProgramExtent::Frames { frames } => { - println!("\n{station}: {title} ({frames} frames)") - } - ProgramExtent::Plateaus { plateaus, .. } => { - println!("\n{station}: {title} ({plateaus} plateaus)") - } - }, - RunEvent::Frame { - index, description, .. - } => println!(" [{index:>3}] {description}"), - RunEvent::ThermalRunning { .. } => println!( - "running; completion may take hours — the wave resumes with --resume if interrupted" - ), - RunEvent::ThermalWarning { station, warning } => { - println!("{station} warning: {warning}") - } - RunEvent::ThermalHold { celsius, .. } => { - println!("holding the block at {celsius} °C until retrieval") - } - RunEvent::DoorOpened { station } => println!("{station} door is open"), - RunEvent::DoorClosed { station } => println!("{station} door is closed"), - RunEvent::AttentionRequired { prompt, .. } => println!("\nby hand — {prompt}"), - RunEvent::AttentionReleased { .. } | RunEvent::LabwareMoved { .. } => {} - } - } -} - -pub(crate) fn run_workcell_command( - directory: PathBuf, - dry_run: bool, - yes: bool, - resume: bool, - station_addresses: Vec, - output: &crate::Output, -) -> Result<()> { - let loaded = load_workcell_directory(&directory)?; - let addresses = parse_station_addresses(&station_addresses)?; - - if dry_run { - return output.success( - "dry-run", - serde_json::json!({ "nodes": loaded.nodes.len() }), - render_dry_run(&loaded), - ); - } - - let bench = Bench { - thermocycler_station: loaded.thermocycler_station.clone(), - addresses, - }; - let config = RunConfig { - assume_yes: yes, - resume, - }; - let mut connector = HardwareConnector; - let mut operator = StdinOperator; - let mut sink = HumanSink; - let outcome = run_workcell( - &directory, - &loaded, - &bench, - &config, - &mut connector, - &mut operator, - &mut sink, - &WallClock, - )?; - match outcome { - WorkcellOutcome::Completed { executed, skipped } => output.success( - "run", - serde_json::json!({ "nodes": executed, "skipped": skipped }), - format!( - "completed {executed} coordination node(s){}", - if skipped == 0 { - String::new() - } else { - format!(" ({skipped} skipped as already complete)") - } - ), - ), - WorkcellOutcome::Cancelled => bail!("run cancelled before any motion"), - WorkcellOutcome::Declined { node } => bail!( - "node '{node}' failed: the operator declined; the ledger holds every completed node — resolve the bench and continue with --resume" - ), - WorkcellOutcome::Failed { node, error } => bail!( - "node '{node}' failed: {error}; the ledger holds every completed node — resolve the bench and continue with --resume" - ), - } -} diff --git a/crates/lab-cli/tests/fixtures/material-lot-build.lab b/crates/lab-cli/tests/fixtures/material-lot-build.lab new file mode 100644 index 0000000..bb4fafb --- /dev/null +++ b/crates/lab-cli/tests/fixtures/material-lot-build.lab @@ -0,0 +1,42 @@ +use std.bio.build +use std.bio.designs +use std.bio.golden_gate + +record Reagent + +artifact Reagent: + description?: String + +buy part source: + sbol_identity = "https://example.org/material-lot-test/source" + +buy backbone pSB1C3: + sbol_identity = "https://example.org/material-lot-test/backbone" + +buy restriction_enzyme BsaI: + sbol_identity = "https://example.org/material-lot-test/enzyme" + +buy reagent T4_DNA_ligase: + sbol_identity = "https://example.org/material-lot-test/ligase" + +buy reagent T4_DNA_ligase_buffer: + sbol_identity = "https://example.org/material-lot-test/buffer" + +buy reagent nuclease_free_water: + sbol_identity = "https://example.org/material-lot-test/water" + +product_sequence: DNA = dna("ATGCGTACGTTAGCTA") + +build plasmid product: + sbol_identity = "https://example.org/material-lot-test/product" + sequence = product_sequence + backbone = pSB1C3 + components = [source] + restriction_enzyme = BsaI + assembly_replicates = 1 + require topology == circular + accept sequence == design.sequence + +workflow main() -> Material: + material <- realize product + return material diff --git a/crates/lab-cli/tests/fixtures/minimal-inventory.ttl b/crates/lab-cli/tests/fixtures/minimal-inventory.ttl new file mode 100644 index 0000000..bc568ee --- /dev/null +++ b/crates/lab-cli/tests/fixtures/minimal-inventory.ttl @@ -0,0 +1,33 @@ +@prefix cap: . +@prefix ex: . +@prefix fac: . +@prefix sbol: . + +ex:facility a sbol:TopLevel, fac:Facility ; + sbol:displayId "facility" ; + sbol:hasNamespace ; + sbol:name "Example facility" . + +ex:room a sbol:TopLevel, fac:Zone ; + sbol:displayId "room" ; + sbol:hasNamespace ; + fac:facility ex:facility ; + fac:zoneKind fac:Room ; + fac:isActive true . + +ex:cycler a sbol:TopLevel, fac:Asset ; + sbol:displayId "cycler" ; + sbol:hasNamespace ; + fac:facility ex:facility ; + fac:assetKind fac:Instrument ; + fac:locatedIn ex:room ; + fac:isActive true ; + fac:capability . + + + a sbol:Identified, fac:CapabilityOffering ; + sbol:displayId "thermal_cycling" ; + fac:capabilityKind cap:ThermalCycling ; + fac:qualification fac:Plannable ; + fac:controlMode fac:ReviewedFileControl ; + fac:isActive true . diff --git a/crates/lab-cli/tests/pdf_output.rs b/crates/lab-cli/tests/pdf_output.rs index 8502d84..a7253f2 100644 --- a/crates/lab-cli/tests/pdf_output.rs +++ b/crates/lab-cli/tests/pdf_output.rs @@ -1,4 +1,4 @@ -//! `lab build` typesets every emitted protocol document to PDF, in-process +//! Facility builds typeset every emitted protocol document to PDF, in-process //! and hermetically: fonts are embedded in the binary and the documents //! import only the bundled style sheet, so this runs offline everywhere. @@ -22,28 +22,27 @@ fn copy_dir(from: &Path, to: &Path) { } #[test] -fn build_typesets_every_document_to_pdf() { +fn facility_plan_typesets_every_document_to_pdf() { let example = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples/golden-gate"); let temp = tempfile::tempdir().unwrap(); let project = temp.path().join("golden-gate"); copy_dir(&example, &project); let output = Command::new(env!("CARGO_BIN_EXE_lab")) - .args([ - "build", - project.to_str().unwrap(), - "--target", - "opentrons-ot2", - ]) + .args(["plan", project.to_str().unwrap()]) .output() .unwrap(); assert!( output.status.success(), - "build failed: {}", + "facility plan failed: {}", String::from_utf8_lossy(&output.stderr) ); - let target_root = project.join(".lab/build/opentrons-ot2"); + let plan_root = project.join(".lab/plan"); + let lowering: serde_json::Value = + serde_json::from_slice(&std::fs::read(plan_root.join("facility_lowering.json")).unwrap()) + .unwrap(); + let target_root = plan_root.join(lowering["routes"][0]["output"].as_str().unwrap()); for document in [ "manual_protocol.pdf", "dependency_report.pdf", @@ -63,6 +62,6 @@ fn build_typesets_every_document_to_pdf() { let human = String::from_utf8_lossy(&output.stdout); assert!( human.contains("Documents:"), - "build output lists the typeset documents: {human}" + "plan output lists the typeset documents: {human}" ); } diff --git a/crates/lab-cli/tests/project_workflow.rs b/crates/lab-cli/tests/project_workflow.rs index c0ab4dc..3512064 100644 --- a/crates/lab-cli/tests/project_workflow.rs +++ b/crates/lab-cli/tests/project_workflow.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -21,6 +21,22 @@ fn temporary_project() -> PathBuf { )) } +fn copy_dir(from: &Path, to: &Path) { + std::fs::create_dir_all(to).unwrap(); + for entry in std::fs::read_dir(from).unwrap() { + let entry = entry.unwrap(); + if entry.file_name() == ".lab" { + continue; + } + let destination = to.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_dir(&entry.path(), &destination); + } else { + std::fs::copy(entry.path(), destination).unwrap(); + } + } +} + #[test] fn new_check_build_and_metadata_form_one_project_loop() { let project = temporary_project(); @@ -49,10 +65,71 @@ fn new_check_build_and_metadata_form_one_project_loop() { "{}", String::from_utf8_lossy(&built.stderr) ); + let build_output = String::from_utf8_lossy(&built.stdout); + assert!( + build_output.contains("Build products:\n plasmid starter"), + "{build_output}" + ); + assert!( + !build_output.contains("Facility outputs:"), + "{build_output}" + ); let index_path = project.join(".lab/build/package.json"); let index: Value = serde_json::from_slice(&std::fs::read(index_path).unwrap()).unwrap(); + assert_eq!(index["schema_version"], 6); assert_eq!(index["package"], "test-project"); assert_eq!(index["modules"][0]["module"], "test_project.programs.main"); + assert_eq!( + index["capability_requirements"], + "capability_requirements.json" + ); + assert_eq!(index["capability_instances"], "capability_instances.json"); + assert!(index.get("facility").is_none()); + let requirements: Value = serde_json::from_slice( + &std::fs::read(project.join(".lab/build/capability_requirements.json")).unwrap(), + ) + .unwrap(); + assert_eq!( + requirements["schema_version"], + "lab.capability-requirements.v2" + ); + assert_eq!(requirements["requirements"].as_array().unwrap().len(), 1); + assert_eq!( + requirements["requirements"][0]["capability_kind"], + "https://sbol.io/ns/capability#ArtifactRealization" + ); + assert_eq!( + requirements["requirements"][0]["minimum_qualification"], + "https://sbol.io/ns/facility#Plannable" + ); + assert_eq!( + requirements["requirements"][0]["value_inputs"][0]["argument"], + "design" + ); + assert!( + requirements["requirements"][0] + .get("parameter_constraints") + .is_none() + ); + let instances: Value = serde_json::from_slice( + &std::fs::read(project.join(".lab/build/capability_instances.json")).unwrap(), + ) + .unwrap(); + assert_eq!( + instances["schema_version"], + "lab.capability-requirement-instances.v2" + ); + assert_eq!( + instances["requirements_schema_version"], + "lab.capability-requirements.v2" + ); + assert_eq!(instances["entry"]["module"], "test_project.programs.main"); + assert_eq!(instances["entry"]["workflow"], "main"); + assert_eq!(instances["instances"].as_array().unwrap().len(), 1); + assert_eq!( + instances["instances"][0]["template"], + "test_project.programs.main::main::body[0]" + ); assert!(project.join("lab.lock").is_file()); let metadata = run(&["metadata", &project_text, "--json"]); @@ -67,6 +144,140 @@ fn new_check_build_and_metadata_form_one_project_loop() { std::fs::remove_dir_all(&project).unwrap(); } +#[test] +fn plan_binds_reachable_requirements_to_an_exact_facility_offering() { + let project = temporary_project(); + std::fs::create_dir_all(project.join("src/programs")).unwrap(); + std::fs::create_dir_all(project.join("inventory")).unwrap(); + std::fs::write( + project.join("lab.toml"), + r#"[package] +name = "facility-plan" +version = "0.1.0" +edition = "2026" + +[build] +entry = "src/programs/main.lab" + +[inventory] +document = "inventory/catalog.ttl" +"#, + ) + .unwrap(); + std::fs::write( + project.join("src/programs/main.lab"), + r#"use std.bio.build +use std.bio.designs + +plasmid starter: + sequence = dna("ATGC") + require topology == circular + accept sequence == design.sequence + +workflow main() -> Material: + product <- realize starter + return product +"#, + ) + .unwrap(); + std::fs::write( + project.join("inventory/catalog.ttl"), + r#"@prefix cap: . +@prefix ex: . +@prefix fac: . +@prefix sbol: . + +ex:facility a sbol:TopLevel, fac:Facility ; sbol:displayId "facility" ; + sbol:hasNamespace . +ex:room a sbol:TopLevel, fac:Zone ; sbol:displayId "room" ; + sbol:hasNamespace ; fac:facility ex:facility ; + fac:zoneKind fac:Room ; fac:isActive true . +ex:operator a sbol:TopLevel, fac:Asset ; sbol:displayId "operator" ; + sbol:hasNamespace ; fac:facility ex:facility ; + fac:assetKind fac:Workstation ; fac:locatedIn ex:room ; fac:isActive true ; + fac:capability . + + a sbol:Identified, fac:CapabilityOffering ; sbol:displayId "realization" ; + fac:capabilityKind cap:ArtifactRealization ; fac:qualification fac:Plannable ; + fac:controlMode fac:ManualControl ; fac:isActive true . +"#, + ) + .unwrap(); + let project_text = project.to_string_lossy().into_owned(); + + let planned = run(&["plan", &project_text]); + + assert!( + planned.status.success(), + "{}", + String::from_utf8_lossy(&planned.stderr) + ); + let allocation: Value = serde_json::from_slice( + &std::fs::read(project.join(".lab/plan/facility_allocation.json")).unwrap(), + ) + .unwrap(); + assert_eq!(allocation["schema_version"], "lab.facility-allocation.v1"); + assert_eq!( + allocation["allocations"][0]["offering"], + "https://example.org/facility/operator/realization" + ); + assert_eq!( + allocation["allocations"][0]["asset"], + "https://example.org/facility/operator" + ); + assert!(allocation["allocations"][0].get("adapter").is_none()); + let plan: Value = serde_json::from_slice( + &std::fs::read(project.join(".lab/plan/plan.execution.json")).unwrap(), + ) + .unwrap(); + assert_eq!(plan["format"], "lab.execution-plan.v1"); + assert_eq!(plan["inventory"]["document"], "inventory-source.ttl"); + assert_eq!( + std::fs::read(project.join(".lab/plan/inventory-source.ttl")).unwrap(), + std::fs::read(project.join("inventory/catalog.ttl")).unwrap() + ); + assert_eq!(plan["requirements"].as_array().unwrap().len(), 1); + assert_eq!(plan["nodes"][0]["action"], "execute"); + assert_eq!( + plan["nodes"][0]["requirement"], + plan["requirements"][0]["requirement_instance"] + ); + + let plan_directory = project.join(".lab/plan"); + let reviewed = run(&["run", plan_directory.to_str().unwrap(), "--dry-run"]); + assert!( + reviewed.status.success(), + "{}", + String::from_utf8_lossy(&reviewed.stderr) + ); + assert!(String::from_utf8_lossy(&reviewed.stdout).contains("all frozen inputs validated")); + assert!(String::from_utf8_lossy(&reviewed.stdout).contains("planning-only bindings")); + + let live = run(&["run", plan_directory.to_str().unwrap(), "--yes"]); + assert!(!live.status.success()); + assert!(String::from_utf8_lossy(&live.stderr).contains("reviewed plan is not ready for live")); + assert!(!plan_directory.join("run-ledger.jsonl").exists()); +} + +#[test] +fn run_requires_a_reviewed_facility_plan() { + let directory = temporary_project(); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write( + directory.join("automation_manifest.json"), + r#"{"schema_version":"lab.automation.v1","adapter":"hamilton.star"}"#, + ) + .unwrap(); + + let attempted = run(&["run", directory.to_str().unwrap(), "--dry-run"]); + + assert!(!attempted.status.success()); + let stderr = String::from_utf8_lossy(&attempted.stderr); + assert!(stderr.contains("failed to read reviewed plan"), "{stderr}"); + assert!(stderr.contains("plan.execution.json"), "{stderr}"); + std::fs::remove_dir_all(directory).unwrap(); +} + #[test] fn registry_dependencies_fail_closed_without_being_ignored() { let project = temporary_project(); @@ -86,26 +297,158 @@ fn registry_dependencies_fail_closed_without_being_ignored() { } #[test] -fn a_target_build_emits_automation_protocols_for_every_wave() { +fn check_validates_a_configured_sbol_inventory() { + let project = temporary_project(); + let project_text = project.to_string_lossy().into_owned(); + let created = run(&["new", &project_text]); + assert!(created.status.success()); + + let manifest = project.join("lab.toml"); + let mut text = std::fs::read_to_string(&manifest).unwrap(); + text.push_str("\n[inventory]\ndocument = \"inventory/catalog.ttl\"\n"); + std::fs::write(&manifest, text).unwrap(); + std::fs::create_dir(project.join("inventory")).unwrap(); + let valid = include_str!("fixtures/minimal-inventory.ttl"); + std::fs::write(project.join("inventory/catalog.ttl"), valid).unwrap(); + + let checked = run(&["check", &project_text]); + assert!( + checked.status.success(), + "{}", + String::from_utf8_lossy(&checked.stderr) + ); + + let invalid = valid.replace("fac:isActive true", "fac:isActive \"yes\""); + std::fs::write(project.join("inventory/catalog.ttl"), invalid).unwrap(); + let rejected = run(&["check", &project_text]); + assert!(!rejected.status.success()); + assert!( + String::from_utf8_lossy(&rejected.stderr).contains("does not conform to SBOLInventory"), + "{}", + String::from_utf8_lossy(&rejected.stderr) + ); + + std::fs::remove_dir_all(&project).unwrap(); +} + +#[test] +fn build_freezes_exact_asset_offering_and_adapter_profile_bindings() { + let project = temporary_project(); + let project_text = project.to_string_lossy().into_owned(); + let created = run(&["new", &project_text]); + assert!(created.status.success()); + + let manifest = project.join("lab.toml"); + let mut text = std::fs::read_to_string(&manifest).unwrap(); + text.push_str( + "\n[inventory]\ndocument = \"inventory/catalog.ttl\"\n\n[[execution.adapters]]\nasset = \"https://example.org/sbolinventory/cycler\"\ndriver = \"opentrons.ot2\"\nprofile = \"adapters/cycler.toml\"\n", + ); + std::fs::write(&manifest, text).unwrap(); + std::fs::create_dir(project.join("inventory")).unwrap(); + let inventory = format!( + "{}\n{}", + include_str!("fixtures/minimal-inventory.ttl"), + r#"ex:operator a sbol:TopLevel, fac:Asset ; + sbol:displayId "operator" ; + sbol:hasNamespace ; + fac:facility ex:facility ; + fac:assetKind fac:Workstation ; + fac:locatedIn ex:room ; + fac:isActive true ; + fac:capability . + + + a sbol:Identified, fac:CapabilityOffering ; + sbol:displayId "artifact_realization" ; + fac:capabilityKind cap:ArtifactRealization ; + fac:qualification fac:Plannable ; + fac:controlMode fac:ManualControl ; + fac:isActive true ."#, + ); + std::fs::write(project.join("inventory/catalog.ttl"), inventory).unwrap(); + std::fs::create_dir(project.join("adapters")).unwrap(); + std::fs::write(project.join("adapters/cycler.toml"), "").unwrap(); + + let checked = run(&["check", &project_text]); + assert!( + checked.status.success(), + "{}", + String::from_utf8_lossy(&checked.stderr) + ); + let built = run(&["build", &project_text]); + assert!( + built.status.success(), + "{}", + String::from_utf8_lossy(&built.stderr) + ); + + let index: Value = + serde_json::from_slice(&std::fs::read(project.join(".lab/build/package.json")).unwrap()) + .unwrap(); + assert_eq!(index["schema_version"], 6); + assert_eq!(index["adapter_bindings"], "adapter_bindings.json"); + assert_eq!( + index["facility"]["facility"], + "https://example.org/sbolinventory/facility" + ); + let bindings: Value = serde_json::from_slice( + &std::fs::read(project.join(".lab/build/adapter_bindings.json")).unwrap(), + ) + .unwrap(); + assert_eq!(bindings["schema_version"], "lab.adapter-bindings.v2"); + assert_eq!( + bindings["facility"], + "https://example.org/sbolinventory/facility" + ); + assert_eq!(bindings["bindings"][0]["driver"], "opentrons.ot2"); + assert_eq!( + bindings["bindings"][0]["asset"], + "https://example.org/sbolinventory/cycler" + ); + assert_eq!( + bindings["bindings"][0]["offerings"][0]["offering"], + "https://example.org/sbolinventory/cycler/thermal_cycling" + ); + assert_eq!( + bindings["bindings"][0]["offerings"][0]["planning_eligible"], + true + ); + assert_eq!( + bindings["bindings"][0]["offerings"][0]["execution_eligible"], + false + ); + assert_eq!( + bindings["bindings"][0]["profile_sha256"] + .as_str() + .unwrap() + .len(), + 64 + ); + + std::fs::remove_dir_all(&project).unwrap(); +} + +#[test] +fn facility_lowering_emits_automation_protocols_for_every_wave() { let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../examples/golden-gate") .canonicalize() .unwrap(); let out_dir = std::env::temp_dir().join(format!( - "lab-golden-gate-target-{}-{}", + "lab-golden-gate-facility-lowering-{}-{}", std::process::id(), line!() )); if out_dir.exists() { std::fs::remove_dir_all(&out_dir).unwrap(); } + std::fs::create_dir_all(out_dir.join("lowerings/stale")).unwrap(); + std::fs::write(out_dir.join("lowerings/stale/protocol.py"), "stale").unwrap(); let output = Command::new(env!("CARGO_BIN_EXE_lab")) .args([ - "build", + "plan", example.to_str().unwrap(), - "--target", - "opentrons-ot2", "--out-dir", out_dir.to_str().unwrap(), "--json", @@ -114,17 +457,13 @@ fn a_target_build_emits_automation_protocols_for_every_wave() { .unwrap(); assert!( output.status.success(), - "target build failed: {}", + "facility plan failed: {}", String::from_utf8_lossy(&output.stderr) ); + assert!(!out_dir.join("lowerings").exists()); let result: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(result["status"], "built"); - assert_eq!(result["result"]["target"], "opentrons-ot2"); - assert_eq!( - result["result"]["modules"], 6, - "designs, workflows, and the program lower as one program" - ); - // The build names every runnable protocol, so a path can go straight into + assert_eq!(result["status"], "planned"); + // Planning names every runnable protocol, so a path can go straight into // a device application. let protocols = result["result"]["protocols"].as_array().unwrap(); assert_eq!(protocols.len(), 3); @@ -136,10 +475,8 @@ fn a_target_build_emits_automation_protocols_for_every_wave() { ); let human = Command::new(env!("CARGO_BIN_EXE_lab")) .args([ - "build", + "plan", example.to_str().unwrap(), - "--target", - "opentrons-ot2", "--out-dir", out_dir.to_str().unwrap(), ]) @@ -152,7 +489,10 @@ fn a_target_build_emits_automation_protocols_for_every_wave() { "{printed}" ); - let target_root = out_dir.join("opentrons-ot2"); + let lowering: Value = + serde_json::from_slice(&std::fs::read(out_dir.join("facility_lowering.json")).unwrap()) + .unwrap(); + let target_root = out_dir.join(lowering["routes"][0]["output"].as_str().unwrap()); // Assembly precedes transformation, and every artifact in a wave shares // one robot run. assert!(target_root.join("wave-001/assembly_protocol.py").is_file()); @@ -172,20 +512,20 @@ fn a_target_build_emits_automation_protocols_for_every_wave() { assert_eq!( manifest["deck"]["stages"]["plating"]["agar_plate"]["slots"], serde_json::json!(["5", "6"]), - "the emitted plan carries the deck the target profile declared" + "the allocated adapter emits the concrete deck plan" ); std::fs::remove_dir_all(out_dir).unwrap(); } #[test] -fn the_manifest_target_builds_automation_protocols_without_naming_one() { +fn build_emits_facility_selected_protocol_bundles_and_documents() { let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../examples/golden-gate") .canonicalize() .unwrap(); let out_dir = std::env::temp_dir().join(format!( - "lab-golden-gate-default-target-{}-{}", + "lab-golden-gate-facility-build-{}-{}", std::process::id(), line!() )); @@ -193,7 +533,7 @@ fn the_manifest_target_builds_automation_protocols_without_naming_one() { std::fs::remove_dir_all(&out_dir).unwrap(); } - let default_target = Command::new(env!("CARGO_BIN_EXE_lab")) + let built = Command::new(env!("CARGO_BIN_EXE_lab")) .args([ "build", example.to_str().unwrap(), @@ -204,55 +544,109 @@ fn the_manifest_target_builds_automation_protocols_without_naming_one() { .output() .unwrap(); assert!( - default_target.status.success(), - "default target build failed: {}", - String::from_utf8_lossy(&default_target.stderr) + built.status.success(), + "facility build failed: {}", + String::from_utf8_lossy(&built.stderr) ); - let result: Value = serde_json::from_slice(&default_target.stdout).unwrap(); - assert_eq!(result["result"]["target"], "opentrons-ot2"); + let result: Value = serde_json::from_slice(&built.stdout).unwrap(); + assert!(result["result"].get("target").is_none()); + assert!(result["result"].get("protocols").is_none()); + assert!(result["result"].get("documents").is_none()); + assert_eq!(result["result"]["products"].as_array().unwrap().len(), 6); + assert_eq!( + result["result"]["products"] + .as_array() + .unwrap() + .iter() + .map(|product| product["name"].as_str().unwrap()) + .collect::>(), + [ + "composite_plasmid_1", + "composite_plasmid_2", + "composite_strain_1", + "composite_strain_2", + "composite_strain_3", + "composite_strain_4", + ] + ); + let facility = &result["result"]["facility"]; + assert_eq!( + facility["facility"], + "https://example.org/golden-gate/facility" + ); + assert_eq!(facility["bundles"].as_array().unwrap().len(), 1); + assert_eq!(facility["protocols"].as_array().unwrap().len(), 3); + assert_eq!(facility["documents"].as_array().unwrap().len(), 4); + for path in facility["protocols"] + .as_array() + .unwrap() + .iter() + .chain(facility["documents"].as_array().unwrap()) + { + assert!(Path::new(path.as_str().unwrap()).is_file(), "{path}"); + } + assert!(out_dir.join("plan.execution.json").is_file()); + assert!(out_dir.join("assets/opentrons_ot2").is_dir()); + assert!(!out_dir.join("lowerings").exists()); + assert!(out_dir.join("package.json").is_file()); + let index: Value = + serde_json::from_slice(&std::fs::read(out_dir.join("package.json")).unwrap()).unwrap(); + assert_eq!(index["adapter_bindings"], "adapter_bindings.json"); + assert_eq!(index["schema_version"], 6); + assert_eq!(index["facility"]["protocols"].as_array().unwrap().len(), 3); assert!( - out_dir - .join("opentrons-ot2/wave-001/assembly_protocol.py") - .is_file() + index["facility"]["protocols"][0] + .as_str() + .unwrap() + .starts_with("assets/opentrons_ot2/") ); - // The default is reversible: a build can still stop at portable module IR. - std::fs::remove_dir_all(&out_dir).unwrap(); - let ir_only = Command::new(env!("CARGO_BIN_EXE_lab")) + let human = Command::new(env!("CARGO_BIN_EXE_lab")) .args([ "build", example.to_str().unwrap(), "--out-dir", out_dir.to_str().unwrap(), - "--no-target", - "--json", ]) .output() .unwrap(); assert!( - ir_only.status.success(), - "{}", - String::from_utf8_lossy(&ir_only.stderr) + human.status.success(), + "facility build failed: {}", + String::from_utf8_lossy(&human.stderr) ); - let result: Value = serde_json::from_slice(&ir_only.stdout).unwrap(); - assert_eq!(result["result"]["target"], Value::Null); - assert!(result["result"]["protocols"].as_array().unwrap().is_empty()); - assert!(!out_dir.join("opentrons-ot2").exists()); - assert!(out_dir.join("package.json").is_file()); + let printed = String::from_utf8(human.stdout).unwrap(); + assert!(printed.contains("Asset bundles:"), "{printed}"); + assert!( + printed.contains(&format!( + "\n {}", + out_dir.join("assets/opentrons_ot2").display() + )), + "{printed}" + ); + assert!( + printed.contains(&format!( + "Allocation: {}", + out_dir.join("facility_allocation.json").display() + )), + "{printed}" + ); + assert!(printed.contains("Automation protocols:"), "{printed}"); + assert!(printed.contains("assembly_protocol.py"), "{printed}"); + assert!(printed.contains("Documents:"), "{printed}"); + assert!(printed.contains("dependency_report.pdf"), "{printed}"); std::fs::remove_dir_all(out_dir).unwrap(); } -/// The `backend` key a profile declares selects the backend, so the same -/// program builds for a Flex without a source edit. #[test] -fn a_profile_selects_its_backend_and_that_backends_protocol_format() { +fn the_golden_gate_facility_plan_binds_liquid_handling_to_the_ot2() { let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../examples/golden-gate") .canonicalize() .unwrap(); let out_dir = std::env::temp_dir().join(format!( - "lab-golden-gate-flex-{}-{}", + "lab-golden-gate-plan-{}-{}", std::process::id(), line!() )); @@ -262,10 +656,253 @@ fn a_profile_selects_its_backend_and_that_backends_protocol_format() { let output = Command::new(env!("CARGO_BIN_EXE_lab")) .args([ - "build", + "plan", + example.to_str().unwrap(), + "--out-dir", + out_dir.to_str().unwrap(), + "--json", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "facility plan failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let allocation: Value = + serde_json::from_slice(&std::fs::read(out_dir.join("facility_allocation.json")).unwrap()) + .unwrap(); + assert_eq!( + allocation["facility"], + "https://example.org/golden-gate/facility" + ); + assert_eq!(allocation["allocations"].as_array().unwrap().len(), 28); + let liquid_handling = allocation["allocations"] + .as_array() + .unwrap() + .iter() + .filter(|binding| { + binding["capability_kind"] == "https://sbol.io/ns/capability#LiquidHandling" + }) + .collect::>(); + assert!(!liquid_handling.is_empty()); + assert!(liquid_handling.iter().all(|binding| { + binding["asset"] == "https://example.org/golden-gate/opentrons_ot2" + && binding["offering"] + == "https://example.org/golden-gate/opentrons_ot2_liquid_handling" + && binding["adapter"]["driver"] == "opentrons.ot2" + })); + + let lowering: Value = + serde_json::from_slice(&std::fs::read(out_dir.join("facility_lowering.json")).unwrap()) + .unwrap(); + assert_eq!(lowering["schema_version"], "lab.facility-lowering.v1"); + assert_eq!(lowering["inventory_sha256"], allocation["inventory_sha256"]); + assert_eq!(lowering["routes"].as_array().unwrap().len(), 1); + let route = &lowering["routes"][0]; + assert_eq!( + route["asset"], + "https://example.org/golden-gate/opentrons_ot2" + ); + assert_eq!(route["driver"], "opentrons.ot2"); + assert_eq!(route["id"], "opentrons-ot2-5dbf2ae84b40"); + assert_eq!(route["output"], "assets/opentrons_ot2"); + assert_eq!(route["requirements"].as_array().unwrap().len(), 6); + let protocols = route["artifacts"] + .as_array() + .unwrap() + .iter() + .filter(|artifact| artifact["role"] == "automation_protocol") + .collect::>(); + assert_eq!(protocols.len(), 3); + assert!(protocols.iter().all(|artifact| { + artifact["format"] == "opentrons.python-protocol" + && artifact["sha256"].as_str().unwrap().len() == 64 + && out_dir + .join(route["output"].as_str().unwrap()) + .join(artifact["path"].as_str().unwrap()) + .is_file() + })); + + let execution_plan: Value = + serde_json::from_slice(&std::fs::read(out_dir.join("plan.execution.json")).unwrap()) + .unwrap(); + let reviewed_lowering = &execution_plan["lowerings"][0]; + assert_eq!(reviewed_lowering["id"], route["id"]); + assert_eq!(reviewed_lowering["asset"], route["asset"]); + assert_eq!(reviewed_lowering["adapter"]["driver"], "opentrons.ot2"); + assert_eq!( + reviewed_lowering["requirements"].as_array().unwrap().len(), + 6 + ); + let reviewed_protocols = reviewed_lowering["artifacts"] + .as_array() + .unwrap() + .iter() + .filter(|artifact| artifact["role"] == "device_protocol") + .collect::>(); + assert_eq!(reviewed_protocols.len(), 3); + assert!(reviewed_protocols.iter().all(|artifact| { + artifact["format"] == "opentrons.python-protocol" + && artifact["sha256"].as_str().unwrap().len() == 64 + && out_dir.join(artifact["path"].as_str().unwrap()).is_file() + })); + + let dry_run = Command::new(env!("CARGO_BIN_EXE_lab")) + .args(["run", out_dir.to_str().unwrap(), "--dry-run"]) + .output() + .unwrap(); + assert!( + dry_run.status.success(), + "reviewed plan failed preflight: {}", + String::from_utf8_lossy(&dry_run.stderr) + ); + assert!(String::from_utf8_lossy(&dry_run.stdout).contains("reviewed adapter lowerings")); + + let tampered = reviewed_protocols[0]["path"].as_str().unwrap(); + std::fs::write(out_dir.join(tampered), "# changed after review\n").unwrap(); + let rejected = Command::new(env!("CARGO_BIN_EXE_lab")) + .args(["run", out_dir.to_str().unwrap(), "--dry-run"]) + .output() + .unwrap(); + assert!(!rejected.status.success()); + assert!( + String::from_utf8_lossy(&rejected.stderr).contains("SHA-256"), + "{}", + String::from_utf8_lossy(&rejected.stderr) + ); + + std::fs::remove_dir_all(out_dir).unwrap(); +} + +#[test] +fn the_extended_golden_gate_example_uses_exact_material_lots_and_the_ot2() { + let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/golden-gate-extended") + .canonicalize() + .unwrap(); + let output_root = temporary_project(); + let plan_dir = output_root.join("plan"); + + let planned = Command::new(env!("CARGO_BIN_EXE_lab")) + .args([ + "plan", example.to_str().unwrap(), - "--target", - "opentrons-flex", + "--out-dir", + plan_dir.to_str().unwrap(), + "--json", + ]) + .output() + .unwrap(); + assert!( + planned.status.success(), + "extended Golden Gate facility plan failed: {}", + String::from_utf8_lossy(&planned.stderr) + ); + let result: Value = serde_json::from_slice(&planned.stdout).unwrap(); + assert_eq!(result["result"]["protocols"].as_array().unwrap().len(), 5); + + let lowering: Value = + serde_json::from_slice(&std::fs::read(plan_dir.join("facility_lowering.json")).unwrap()) + .unwrap(); + assert_eq!(lowering["routes"].as_array().unwrap().len(), 1); + let route = &lowering["routes"][0]; + let manifest: Value = serde_json::from_slice( + &std::fs::read( + plan_dir + .join(route["output"].as_str().unwrap()) + .join("dependency_manifest.json"), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(manifest["inventory"]["kind"], "sbol_inventory"); + assert_eq!( + manifest["inventory"]["facility"], + "https://example.org/golden-gate/facility" + ); + let reference_binding = manifest["nodes"] + .as_array() + .unwrap() + .iter() + .flat_map(|node| node["material_lot_bindings"].as_array().unwrap()) + .find(|binding| binding["symbol"] == "reference_gfp") + .unwrap(); + assert_eq!( + reference_binding["component"], + "https://example.org/golden-gate/materials/reference_gfp" + ); + assert_eq!( + reference_binding["material_lot"], + "https://example.org/golden-gate/lots/reference_gfp_lot" + ); + + let allocation: Value = + serde_json::from_slice(&std::fs::read(plan_dir.join("facility_allocation.json")).unwrap()) + .unwrap(); + assert_eq!(allocation["allocations"].as_array().unwrap().len(), 30); + let liquid_handling = allocation["allocations"] + .as_array() + .unwrap() + .iter() + .filter(|binding| { + binding["capability_kind"] == "https://sbol.io/ns/capability#LiquidHandling" + }) + .collect::>(); + assert!(!liquid_handling.is_empty()); + assert!(liquid_handling.iter().all(|binding| { + binding["asset"] == "https://example.org/golden-gate/opentrons_ot2" + && binding["adapter"]["driver"] == "opentrons.ot2" + })); + + let dry_run = Command::new(env!("CARGO_BIN_EXE_lab")) + .args(["run", plan_dir.to_str().unwrap(), "--dry-run"]) + .output() + .unwrap(); + assert!( + dry_run.status.success(), + "extended Golden Gate plan failed preflight: {}", + String::from_utf8_lossy(&dry_run.stderr) + ); + + std::fs::remove_dir_all(output_root).unwrap(); +} + +/// A different facility Asset and exact adapter binding lower the same experiment for a Flex +/// without a source edit or an independent device selector. +#[test] +fn a_facility_binding_selects_the_flex_adapter_and_protocol_format() { + let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/golden-gate") + .canonicalize() + .unwrap(); + let project = temporary_project(); + copy_dir(&example, &project); + let inventory_path = project.join("inventory/facility.ttl"); + let inventory = std::fs::read_to_string(&inventory_path) + .unwrap() + .replace("opentrons_ot2", "opentrons_flex") + .replace("Opentrons OT-2", "Opentrons Flex") + .replace( + "OT-2 with Thermocycler Module Gen2", + "Flex with Thermocycler Module Gen2", + ); + std::fs::write(inventory_path, inventory).unwrap(); + let manifest_path = project.join("lab.toml"); + let manifest = std::fs::read_to_string(&manifest_path) + .unwrap() + .replace("opentrons_ot2", "opentrons_flex") + .replace("opentrons.ot2", "opentrons.flex") + .replace("opentrons-ot2.toml", "opentrons-flex.toml"); + std::fs::write(manifest_path, manifest).unwrap(); + std::fs::write(project.join("adapters/opentrons-flex.toml"), "").unwrap(); + let out_dir = project.join("review"); + + let output = Command::new(env!("CARGO_BIN_EXE_lab")) + .args([ + "plan", + project.to_str().unwrap(), "--out-dir", out_dir.to_str().unwrap(), "--json", @@ -274,22 +911,30 @@ fn a_profile_selects_its_backend_and_that_backends_protocol_format() { .unwrap(); assert!( output.status.success(), - "Flex target build failed: {}", + "Flex facility plan failed: {}", String::from_utf8_lossy(&output.stderr) ); let result: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(result["result"]["target"], "opentrons-flex"); let protocols = result["result"]["protocols"].as_array().unwrap(); assert_eq!(protocols.len(), 3); assert!( protocols .iter() .all(|path| path.as_str().unwrap().ends_with("_protocol.json")), - "a Flex build emits JSON protocols: {protocols:?}" + "the allocated Flex adapter emits JSON protocols: {protocols:?}" ); - let target_root = out_dir.join("opentrons-flex"); + let lowering: Value = + serde_json::from_slice(&std::fs::read(out_dir.join("facility_lowering.json")).unwrap()) + .unwrap(); + let route = &lowering["routes"][0]; + assert_eq!( + route["asset"], + "https://example.org/golden-gate/opentrons_flex" + ); + assert_eq!(route["driver"], "opentrons.flex"); + let target_root = out_dir.join(route["output"].as_str().unwrap()); assert!( target_root .join("wave-001/assembly_protocol.json") @@ -306,11 +951,11 @@ fn a_profile_selects_its_backend_and_that_backends_protocol_format() { &std::fs::read_to_string(target_root.join("wave-002/automation_manifest.json")).unwrap(), ) .unwrap(); - assert_eq!(manifest["target"], "opentrons.flex"); + assert_eq!(manifest["adapter"], "opentrons.flex"); assert_eq!( manifest["deck"]["stages"]["plating"]["agar_plate"]["slots"], serde_json::json!(["B2", "B3"]), - "the emitted plan carries the deck the target profile declared" + "the emitted plan carries the allocated adapter's deck configuration" ); let protocol: Value = serde_json::from_str( @@ -320,60 +965,9 @@ fn a_profile_selects_its_backend_and_that_backends_protocol_format() { assert_eq!(protocol["schemaVersion"], 8); assert_eq!(protocol["robot"]["model"], "OT-3 Standard"); - std::fs::remove_dir_all(out_dir).unwrap(); -} - -#[test] -fn a_target_build_rejects_a_backend_this_toolchain_does_not_provide() { - let project = temporary_project(); - std::fs::create_dir_all(project.join("src/programs")).unwrap(); - std::fs::write( - project.join("lab.toml"), - "[package]\nname = \"unknown-backend\"\nversion = \"0.1.0\"\nedition = \"2026\"\n\n[build]\nentry = \"src/programs/main.lab\"\n", - ) - .unwrap(); - std::fs::write( - project.join("src/programs/main.lab"), - "use std.bio.build\nuse std.bio.designs\n\nplasmid starter:\n sequence = dna(\"ATGC\")\n require topology == circular\n accept sequence == design.sequence\n\nworkflow main() -> Material:\n product <- realize starter\n return product\n", - ) - .unwrap(); - std::fs::create_dir_all(project.join("targets")).unwrap(); - std::fs::write( - project.join("targets/evo.toml"), - "[target]\nbackend = \"tecan.evo\"\n", - ) - .unwrap(); - - let output = Command::new(env!("CARGO_BIN_EXE_lab")) - .args(["build", project.to_str().unwrap(), "--target", "evo"]) - .output() - .unwrap(); - - assert!(!output.status.success()); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains("tecan.evo"), "{stderr}"); - assert!(stderr.contains("opentrons.flex"), "{stderr}"); - assert!(stderr.contains("hamilton.star"), "{stderr}"); - std::fs::remove_dir_all(project).unwrap(); } -#[test] -fn a_target_build_rejects_a_profile_that_does_not_exist() { - let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../examples/golden-gate") - .canonicalize() - .unwrap(); - let output = Command::new(env!("CARGO_BIN_EXE_lab")) - .args(["build", example.to_str().unwrap(), "--target", "no-such"]) - .output() - .unwrap(); - - assert!(!output.status.success()); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains("no target profile at"), "{stderr}"); -} - #[test] fn checking_one_file_underlines_the_source_rather_than_naming_byte_offsets() { let source = temporary_project().with_extension("lab"); @@ -406,174 +1000,3 @@ fn checking_one_file_underlines_the_source_rather_than_naming_byte_offsets() { std::fs::remove_file(&source).unwrap(); } - -#[test] -fn a_workcell_target_lifts_thermal_work_onto_its_cycler_station() { - let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../examples/golden-gate") - .canonicalize() - .unwrap(); - let out_dir = std::env::temp_dir().join(format!( - "lab-golden-gate-workcell-{}-{}", - std::process::id(), - line!() - )); - if out_dir.exists() { - std::fs::remove_dir_all(&out_dir).unwrap(); - } - - let output = Command::new(env!("CARGO_BIN_EXE_lab")) - .args([ - "build", - example.to_str().unwrap(), - "--target", - "workcell-star", - "--out-dir", - out_dir.to_str().unwrap(), - "--json", - ]) - .output() - .unwrap(); - assert!( - output.status.success(), - "workcell target build failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - - let target_root = out_dir.join("workcell-star"); - let coordination: Value = serde_json::from_str( - &std::fs::read_to_string(target_root.join("wave-001/plan.workcell.json")).unwrap(), - ) - .unwrap(); - assert_eq!(coordination["format"], "lab.workcell-run.v0"); - let stations = coordination["stations"].as_array().unwrap(); - assert_eq!(stations.len(), 2, "the profile declares two stations"); - - let nodes = coordination["nodes"].as_array().unwrap(); - assert_eq!( - nodes[0]["action"], "station-program", - "the wave opens with the liquid handler's run" - ); - assert_eq!(nodes[0]["station"], "star-1"); - let actions: Vec<&str> = nodes - .iter() - .map(|node| node["action"].as_str().unwrap()) - .collect(); - assert!( - actions.contains(&"handoff"), - "plate movements are explicit nodes: {actions:?}" - ); - - // The thermal program left the operator prose and became a station - // document the cycler executes. - let cycler_doc: Value = serde_json::from_str( - &std::fs::read_to_string( - target_root.join("wave-001/stations/odtc-1/assembly_thermocycle.odtc.json"), - ) - .unwrap(), - ) - .unwrap(); - assert_eq!(cycler_doc["format"], "lab.thermocycle-run.v0"); - assert_eq!(cycler_doc["plate"], "reaction_plate"); - assert_eq!(cycler_doc["final_hold_celsius"], 4.0); - let stages = cycler_doc["profile"]["stages"].as_array().unwrap(); - assert_eq!( - stages[0]["steps"].as_array().unwrap().len(), - 2, - "digest and ligate alternate inside the cycled stage" - ); - - // The handler's run document is the same reviewed program it would be - // on a bare STAR target, minus the operator prose the coordination - // plan now owns. - let star_doc: Value = serde_json::from_str( - &std::fs::read_to_string( - target_root.join("wave-001/stations/star-1/assembly_run.star.json"), - ) - .unwrap(), - ) - .unwrap(); - assert_eq!(star_doc["format"], "lab.star-run.v0"); - assert_eq!( - star_doc["manual_after"].as_array().unwrap().len(), - 0, - "sequencing lives in plan.workcell.json, not in the station package" - ); - assert!( - target_root - .join("wave-001/stations/star-1/manual_protocol.typ") - .is_file(), - "deck and source loading stay with the handler's own manual" - ); - let station_manual = - std::fs::read(target_root.join("wave-001/stations/star-1/manual_protocol.pdf")).unwrap(); - assert!( - station_manual.starts_with(b"%PDF-"), - "every emitted document is typeset beside its source" - ); - - // Later waves carry the transformation thermal programs. - assert!( - target_root - .join("wave-002/stations/odtc-1/transformation_heat_shock.odtc.json") - .is_file() - ); - assert!( - target_root - .join("wave-002/stations/odtc-1/transformation_recovery.odtc.json") - .is_file() - ); - - std::fs::remove_dir_all(&out_dir).unwrap(); -} - -#[test] -fn a_workcell_wave_dry_runs_through_the_coordination_plan() { - let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../examples/golden-gate") - .canonicalize() - .unwrap(); - let out_dir = std::env::temp_dir().join(format!( - "lab-golden-gate-workcell-run-{}-{}", - std::process::id(), - line!() - )); - if out_dir.exists() { - std::fs::remove_dir_all(&out_dir).unwrap(); - } - let build = Command::new(env!("CARGO_BIN_EXE_lab")) - .args([ - "build", - example.to_str().unwrap(), - "--target", - "workcell-star", - "--out-dir", - out_dir.to_str().unwrap(), - ]) - .output() - .unwrap(); - assert!( - build.status.success(), - "workcell build failed: {}", - String::from_utf8_lossy(&build.stderr) - ); - - let wave = out_dir.join("workcell-star/wave-002"); - let output = Command::new(env!("CARGO_BIN_EXE_lab")) - .args(["run", wave.to_str().unwrap(), "--dry-run", "--json"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "workcell dry run failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let result: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(result["status"], "dry-run"); - assert!( - result["result"]["nodes"].as_u64().unwrap() >= 8, - "the transformation wave coordinates runs, thermal programs, and handoffs: {result}" - ); - - std::fs::remove_dir_all(&out_dir).unwrap(); -} diff --git a/crates/lab-compiler/Cargo.toml b/crates/lab-compiler/Cargo.toml index 545b936..6fc25ec 100644 --- a/crates/lab-compiler/Cargo.toml +++ b/crates/lab-compiler/Cargo.toml @@ -27,8 +27,10 @@ clap.workspace = true # features off). hamilton-star.workspace = true lab-instruments.workspace = true +lab-inventory.workspace = true lab-runfmt.workspace = true lab-language.workspace = true +sbol-inventory.workspace = true opentrons-protocol.workspace = true pliron.workspace = true serde.workspace = true @@ -38,6 +40,9 @@ sha2.workspace = true thiserror.workspace = true toml.workspace = true +[dev-dependencies] +tempfile = "3.27.0" + [dev-dependencies.cargo-husky] version = "1" default-features = false diff --git a/crates/lab-compiler/README.md b/crates/lab-compiler/README.md index 035afbf..8a1d768 100644 --- a/crates/lab-compiler/README.md +++ b/crates/lab-compiler/README.md @@ -4,28 +4,30 @@ `CheckedModule` is the portable source-compilation boundary, and verified LAIR is the mandatory backend boundary. Source lowering constructs a `PortableLairProgram` containing Design and Workflow LAIR. Protocol selection consumes that program and returns a `ProtocolLairProgram`; typed robot backends consume only that verified Protocol boundary and cannot accept a checked source module directly. Artifact emission remains a separate operation. Output selection therefore does not choose a different parser or semantic pipeline or bypass LAIR. -This is the first vertical slice of a larger progressive lowering stack. LAIR preserves high-level biological and workflow intent while later dialects select laboratory methods, bind materials and resources, schedule work, and finally produce target-specific operations for instruments, people, and services. A laboratory profile describes available capabilities and policy preferences; a backend implements an execution target such as a robot family. +This is the first vertical slice of a larger progressive lowering stack. LAIR preserves high-level biological and workflow intent while later dialects select laboratory methods, bind materials and resources, schedule work, and finally produce device-specific operations for instruments, people, and services. An SBOLInventory facility graph describes installed capability offerings, while an adapter implements planning, lowering, simulation, or execution for exact Assets selected by reviewed plans. -The current Protocol IR is target-selected but not hardware-level. Containers, inventory lots, locations, timing, scheduling, deck geometry, device commands, and durable dispatch belong to later lowering and runtime layers. +The current Protocol IR is method-selected but not hardware-level. Containers, inventory lots, locations, timing, scheduling, deck geometry, device commands, and durable dispatch belong to later facility, adapter, and runtime layers. The source tree follows semantic ownership and dependency direction: - `src/lair/` contains Design, Workflow, and Protocol dialects; the Workflow-to-Protocol dialect conversion; material-linearity analysis and pass; stage contracts; and the textual IR session; - `src/planning/` resolves artifact graphs against inventory without robot knowledge; -- `src/backend/` defines backend contracts and contains the concrete backends, grouped by vendor family: `opentrons/ot2/`, `opentrons/flex/`, `hamilton/star/`, and `workcell/`, which composes stations; +- `src/backend/` defines the adapter registry and concrete single-device compilers, grouped by vendor family under `opentrons/` and `hamilton/`; - `src/artifact/` defines generated files independently of filesystem persistence; -- `src/runfmt/` defines the run-document formats the `lab` runner interprets (`lab.workcell-run.v0`, `lab.star-run.v0`, `lab.thermocycle-run.v0`, `lab.plate-read.v0`); and +- `lab-runfmt` defines the reviewed documents the `lab` runner interprets, including `lab.execution-plan.v1`, `lab.simulation-run.v1`, `lab.star-run.v0`, `lab.thermocycle-run.v0`, and `lab.plate-read.v0`; and - `src/bin/labc/` and `src/bin/lab-opt/` contain developer-facing command orchestration. The dependency direction is language model → planning/LAIR → backend → artifacts, with command-line applications owning filesystem writes. LAIR and generic planning do not depend on concrete robots. -`labc --emit` can expose the source AST, checked module IR, or an artifact emitted by a backend, and `--target-profile` selects the bench a backend compiles for. `lab-opt` separately parses, verifies, transforms, and prints textual LAIR without acting as another source frontend. Source-to-LAIR lowering lives under `src/lair/`; no production backend module imports `lab-language`. Design LAIR contains only declarative artifact identity, sequence, topology, copy, and acceptance intent. Workflow LAIR preserves `realize`, `provision`, `transform`, `recover`, `dilute`, and `plate` as typed material operations with explicit SSA use-def edges. A Pliron dialect conversion replaces that Workflow dataflow with verifier-valid Protocol operations and then eliminates every Workflow operation. +`lab.adapter-catalog.v1` is the machine-readable implementation contract. Each stable adapter ID declares exact SBOLInventory capability-kind and control-mode IRIs, implementation features, accepted and emitted run-document formats, configuration schema, and truthful planning, simulation, and runtime support. Semantic capabilities and implementation features are deliberately separate. A driver is selected only by an explicit binding to an exact Asset IRI, never by manufacturer or model inference. -The OT-2 backend lives entirely under `src/backend/opentrons/ot2/` and accepts `ProtocolLairProgram`, not source IR or an OT-2-specific copy of the biological recipe. Its planner analyzes Protocol operations and their use-def chains directly, validates target constraints, and allocates an `Ot2ExecutionPlan`. The manifest, manual protocol, and three OT-2 protocol stages are rendered from that one execution plan. Robot constants, deck capacities, labware choices, Python generation, and robot-specific package text do not live in generic rendering, planning, or the language frontend. Robot behavior is maintained in the backend-local `python/` project and checked with Ruff, strict mypy, pytest, byte compilation, and Opentrons simulation; Rust bundles those Python modules and injects the execution plan. +`labc --emit` can expose the source AST, checked module IR, or an artifact emitted by a backend; its developer-only `--adapter` and `--adapter-profile` arguments choose one explicit low-level implementation without allowing the profile to select code. `lab-opt` separately parses, verifies, transforms, and prints textual LAIR without acting as another source frontend. Source-to-LAIR lowering lives under `src/lair/`; no production backend module imports `lab-language`. Design LAIR contains only declarative artifact identity, sequence, topology, copy, and acceptance intent. Workflow LAIR preserves `realize`, `provision`, `transform`, `recover`, `dilute`, and `plate` as typed material operations with explicit SSA use-def edges. A Pliron dialect conversion replaces that Workflow dataflow with verifier-valid Protocol operations and then eliminates every Workflow operation. -`dependency-plan` and `full-build-bundle` first use the target-neutral resolver in `src/planning/` to resolve source-declared artifact dependencies against a JSON inventory. The OT-2 package layer then compiles each successful graph node. A full-build bundle includes one consolidated human protocol in dependency-safe execution order, a separate dependency report, and standalone human/robot artifacts for each planning wave. Artifacts in one wave have no ordering constraint between them, so a wave is a single robot run over one deck. Backends return an `ArtifactBundle`; `labc` is responsible for writing it to disk. +The OT-2 backend lives entirely under `src/backend/opentrons/ot2/` and accepts `ProtocolLairProgram`, not source IR or an OT-2-specific copy of the biological recipe. Its planner analyzes Protocol operations and their use-def chains directly, validates adapter constraints, and allocates an `Ot2ExecutionPlan`. The manifest, manual protocol, and three OT-2 protocol stages are rendered from that one execution plan. Robot constants, deck capacities, labware choices, Python generation, and robot-specific package text do not live in generic rendering, planning, or the language frontend. Robot behavior is maintained in the backend-local `python/` project and checked with Ruff, strict mypy, pytest, byte compilation, and Opentrons simulation; Rust bundles those Python modules and injects the execution plan. -`labc` compiles one source file, so the modules it accepts are self-contained; a multi-module package is `lab build`'s job. The single-module sources these commands are exercised against live in [`tests/fixtures/`](tests/fixtures/). For the end-to-end package — designs, target profile, and the OT-2 protocols a robot application can open — see the [Golden Gate example](../../examples/golden-gate/README.md). +`dependency-plan` and `full-build-bundle` first use the facility-independent resolver in `src/planning/` to resolve source-declared artifact dependencies against supplied inventory evidence. The OT-2 package layer then compiles each successful graph node. A full-build bundle includes one consolidated human protocol in dependency-safe execution order, a separate dependency report, and standalone human/robot artifacts for each planning wave. Artifacts in one wave have no ordering constraint between them, so a wave is a single robot run over one deck. Adapters return an `ArtifactBundle`; `labc` is responsible for writing it to disk. + +`labc` compiles one source file, so the modules it accepts are self-contained; a multi-module package is `lab build`'s job. The single-module sources these commands are exercised against live in [`tests/fixtures/`](tests/fixtures/). For the end-to-end package — designs, SBOLInventory facility, exact adapter binding, and the OT-2 protocols a robot application can open — see the [Golden Gate example](../../examples/golden-gate/README.md). The end-to-end test runs every generated protocol through the official Opentrons simulator when `LAB_OPENTRONS_SIMULATOR` points at the executable. It stays optional so ordinary CI does not download the large robotics runtime: diff --git a/crates/lab-compiler/src/backend/adapters.rs b/crates/lab-compiler/src/backend/adapters.rs new file mode 100644 index 0000000..6387070 --- /dev/null +++ b/crates/lab-compiler/src/backend/adapters.rs @@ -0,0 +1,606 @@ +//! Compiler-owned adapter discovery and profile validation. +//! +//! An adapter is a Lab implementation, never a facility Asset. The manifest binds an adapter ID +//! to an exact SBOLInventory Asset IRI; this registry states which semantic capability offerings +//! and control modes that implementation can use. Product features stay separate from semantic +//! capability kinds so neither manufacturer nor model can silently select a driver. + +use std::collections::BTreeSet; + +use sbol_inventory::vocabulary::{ + ABSORBANCE_MEASUREMENT, ControlMode, INCUBATION, LIQUID_HANDLING, THERMAL_CYCLING, +}; +use schemars::{JsonSchema, schema_for}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use thiserror::Error; + +use crate::backend::hamilton::star::StarAdapterProfile; +use crate::backend::opentrons::flex::FlexAdapterProfile; +use crate::backend::opentrons::ot2::Ot2AdapterProfile; +use crate::planning::BuildInventory; +use crate::{ArtifactBundle, ProtocolLairProgram}; +use lab_runfmt::{SIMULATION_RUN_FORMAT, STAR_RUN_FORMAT, THERMOCYCLE_RUN_FORMAT}; + +pub const ADAPTER_CATALOG_FORMAT: &str = "lab.adapter-catalog.v1"; +pub const ADAPTER_PROFILE_SCHEMA_VERSION: &str = "lab.adapter-profile.v2"; + +const OPENTRONS_PYTHON_PROTOCOL: &str = "opentrons.python-protocol"; +const OPENTRONS_PROTOCOL_DESIGNER: &str = "opentrons.protocol-designer-json"; + +const KNOWN_ADAPTERS: [&str; 6] = [ + "opentrons.ot2", + "opentrons.flex", + "hamilton.star", + "inheco.odtc", + "byonoy.absorbance96", + "lab.simulator", +]; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AdapterServices { + pub planning: bool, + /// This adapter can lower a complete checked Lab program into device artifacts. + pub lowering: bool, + pub simulation: bool, + pub runtime: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AdapterDescriptor { + pub id: String, + pub display_name: String, + pub manufacturer: Option, + /// Exact SBOLInventory `fac:capabilityKind` IRIs this implementation supports. + pub capabilities: BTreeSet, + /// Implementation facts that must never be used as semantic capability kinds. + pub features: BTreeSet, + /// Exact closed SBOLInventory control-mode IRIs this implementation supports. + pub control_modes: BTreeSet, + pub accepted_run_formats: BTreeSet, + pub emitted_run_formats: BTreeSet, + pub services: AdapterServices, + pub profile_schema: Value, + pub default_profile: ValidatedAdapterProfile, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AdapterCatalog { + pub format: String, + pub compiler_version: String, + pub profile_schema_version: String, + pub adapters: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ValidatedAdapterProfile { + pub format: String, + pub schema_version: String, + pub compiler_version: String, + pub name: String, + pub driver: String, + pub canonical_toml: String, + pub canonical_json: Value, + pub sha256: String, +} + +#[derive(Debug, Error)] +pub enum AdapterProfileContractError { + #[error( + "adapter driver '{found}' is not provided by this compiler; known adapters are {known}" + )] + UnknownDriver { found: String, known: String }, + #[error("invalid {driver} adapter profile: {message}")] + Invalid { driver: String, message: String }, + #[error("failed to describe adapter profiles: {0}")] + Contract(String), + #[error("failed to parse adapter profile TOML: {0}")] + Toml(#[from] toml::de::Error), +} + +/// Describes every concrete adapter in this compiler build. +pub fn adapter_catalog() -> Result { + Ok(AdapterCatalog { + format: ADAPTER_CATALOG_FORMAT.to_owned(), + compiler_version: env!("CARGO_PKG_VERSION").to_owned(), + profile_schema_version: ADAPTER_PROFILE_SCHEMA_VERSION.to_owned(), + adapters: vec![ + descriptor( + "opentrons.ot2", + "Opentrons OT-2", + Some("Opentrons"), + [LIQUID_HANDLING, THERMAL_CYCLING], + ["on-deck-modules", "python-protocol-api", "single-channel"], + [ControlMode::ReviewedFile], + [], + [OPENTRONS_PYTHON_PROTOCOL], + AdapterServices { + planning: true, + lowering: true, + simulation: false, + runtime: false, + }, + schema_value::()?, + )?, + descriptor( + "opentrons.flex", + "Opentrons Flex", + Some("Opentrons"), + [LIQUID_HANDLING, THERMAL_CYCLING], + ["on-deck-modules", "protocol-designer-json"], + [ControlMode::ReviewedFile], + [], + [OPENTRONS_PROTOCOL_DESIGNER], + AdapterServices { + planning: true, + lowering: true, + simulation: false, + runtime: false, + }, + schema_value::()?, + )?, + descriptor( + "hamilton.star", + "Hamilton STAR/STARlet", + Some("Hamilton"), + [LIQUID_HANDLING], + ["eight-channel", "firmware-frames", "live-usb"], + [ControlMode::ReviewedFile, ControlMode::Api], + [STAR_RUN_FORMAT], + [STAR_RUN_FORMAT], + AdapterServices { + planning: true, + lowering: true, + simulation: true, + runtime: true, + }, + schema_value::()?, + )?, + descriptor( + "inheco.odtc", + "Inheco ODTC", + Some("Inheco"), + [THERMAL_CYCLING], + ["network-session", "thermal-profile"], + [ControlMode::Sila2], + [THERMOCYCLE_RUN_FORMAT], + [THERMOCYCLE_RUN_FORMAT], + AdapterServices { + planning: true, + lowering: false, + simulation: true, + runtime: true, + }, + schema_value::()?, + )?, + descriptor( + "byonoy.absorbance96", + "Byonoy Absorbance 96", + Some("Byonoy"), + [ABSORBANCE_MEASUREMENT], + ["hid", "plate-reader"], + [ControlMode::Api], + [], + [], + AdapterServices { + planning: false, + lowering: false, + simulation: false, + runtime: false, + }, + schema_value::()?, + )?, + descriptor( + "lab.simulator", + "Lab semantic capability simulator", + None, + [LIQUID_HANDLING, INCUBATION, ABSORBANCE_MEASUREMENT], + ["no-hardware", "semantic-simulation"], + [ControlMode::ReviewedFile], + [SIMULATION_RUN_FORMAT], + [SIMULATION_RUN_FORMAT], + AdapterServices { + planning: true, + lowering: false, + simulation: true, + runtime: false, + }, + schema_value::()?, + )?, + ], + }) +} + +#[allow(clippy::too_many_arguments)] +fn descriptor( + id: &'static str, + display_name: &'static str, + manufacturer: Option<&'static str>, + capabilities: [&'static str; C], + features: [&'static str; F], + control_modes: [ControlMode; M], + accepted_run_formats: [&'static str; A], + emitted_run_formats: [&'static str; E], + services: AdapterServices, + profile_schema: Value, +) -> Result { + Ok(AdapterDescriptor { + id: id.to_owned(), + display_name: display_name.to_owned(), + manufacturer: manufacturer.map(str::to_owned), + capabilities: strings(capabilities), + features: strings(features), + control_modes: control_modes + .into_iter() + .map(|mode| mode.iri().to_owned()) + .collect(), + accepted_run_formats: strings(accepted_run_formats), + emitted_run_formats: strings(emitted_run_formats), + services, + profile_schema, + default_profile: default_adapter_profile(id, id)?, + }) +} + +fn strings(values: [&'static str; N]) -> BTreeSet { + values.into_iter().map(str::to_owned).collect() +} + +/// Returns the canonical empty or reference profile for one adapter. +pub fn default_adapter_profile( + driver: &str, + name: &str, +) -> Result { + validate_adapter_profile(driver, name, "") +} + +/// Parses a profile with the schema selected by the explicit adapter ID. +/// +/// The profile cannot select another driver. In particular, an omitted or misleading +/// manufacturer/model value never changes which parser runs. +pub fn validate_adapter_profile( + driver: &str, + name: &str, + contents: &str, +) -> Result { + match driver { + "opentrons.ot2" => { + let profile = + Ot2AdapterProfile::parse(name, contents).map_err(|error| invalid(driver, error))?; + canonical_adapter_profile(driver, name, &profile) + } + "opentrons.flex" => { + let profile = FlexAdapterProfile::parse(name, contents) + .map_err(|error| invalid(driver, error))?; + canonical_adapter_profile(driver, name, &profile) + } + "hamilton.star" => { + let profile = StarAdapterProfile::parse(name, contents) + .map_err(|error| invalid(driver, error))?; + canonical_adapter_profile(driver, name, &profile) + } + "inheco.odtc" | "byonoy.absorbance96" | "lab.simulator" => { + let _: EmptyAdapterProfile = toml::from_str(contents)?; + Ok(empty_profile(driver, name)) + } + other => Err(unknown_driver(other)), + } +} + +/// Lowers one complete checked program through an explicitly selected adapter. +/// +/// Selection has already happened through facility allocation. This function cannot infer a +/// driver from an Asset's manufacturer or model and cannot select a different adapter. The +/// profile is private operational configuration for the exact Asset binding, not a second facility model. +pub fn lower_dependency_build_with_adapter( + driver: &str, + name: &str, + contents: &str, + protocol: &ProtocolLairProgram, + inventory: &BuildInventory, +) -> Result { + match driver { + "opentrons.ot2" => { + let profile = Ot2AdapterProfile::parse(name, contents).map_err(|error| { + AdapterLoweringError::InvalidProfile { + driver: driver.to_owned(), + message: error.to_string(), + } + })?; + lab_compiler_ot2(protocol, &profile, inventory).map_err(|message| { + AdapterLoweringError::Lowering { + driver: driver.to_owned(), + message, + } + }) + } + "opentrons.flex" => { + let profile = FlexAdapterProfile::parse(name, contents).map_err(|error| { + AdapterLoweringError::InvalidProfile { + driver: driver.to_owned(), + message: error.to_string(), + } + })?; + lab_compiler_flex(protocol, &profile, inventory).map_err(|message| { + AdapterLoweringError::Lowering { + driver: driver.to_owned(), + message, + } + }) + } + "hamilton.star" => { + let profile = StarAdapterProfile::parse(name, contents).map_err(|error| { + AdapterLoweringError::InvalidProfile { + driver: driver.to_owned(), + message: error.to_string(), + } + })?; + lab_compiler_star(protocol, &profile, inventory).map_err(|message| { + AdapterLoweringError::Lowering { + driver: driver.to_owned(), + message, + } + }) + } + _ => Err(AdapterLoweringError::Unsupported { + driver: driver.to_owned(), + }), + } +} + +fn lab_compiler_ot2( + protocol: &ProtocolLairProgram, + profile: &Ot2AdapterProfile, + inventory: &BuildInventory, +) -> Result { + crate::backend::opentrons::ot2::compile_dependency_build(protocol, profile, inventory) + .map(|bundle| bundle.artifacts().clone()) + .map_err(|error| error.to_string()) +} + +fn lab_compiler_flex( + protocol: &ProtocolLairProgram, + profile: &FlexAdapterProfile, + inventory: &BuildInventory, +) -> Result { + crate::backend::opentrons::flex::compile_dependency_build(protocol, profile, inventory) + .map(|bundle| bundle.artifacts().clone()) + .map_err(|error| error.to_string()) +} + +fn lab_compiler_star( + protocol: &ProtocolLairProgram, + profile: &StarAdapterProfile, + inventory: &BuildInventory, +) -> Result { + crate::backend::hamilton::star::compile_dependency_build(protocol, profile, inventory) + .map(|bundle| bundle.artifacts().clone()) + .map_err(|error| error.to_string()) +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum AdapterLoweringError { + #[error("adapter '{driver}' does not provide whole-program lowering")] + Unsupported { driver: String }, + #[error("invalid operational profile for adapter '{driver}': {message}")] + InvalidProfile { driver: String, message: String }, + #[error("adapter '{driver}' could not lower the allocated program: {message}")] + Lowering { driver: String, message: String }, +} + +fn schema_value() -> Result { + let mut schema = serde_json::to_value(schema_for!(T)) + .map_err(|error| AdapterProfileContractError::Contract(error.to_string()))?; + sanitize_schema_defaults(&mut schema); + Ok(schema) +} + +fn sanitize_schema_defaults(value: &mut Value) { + let definitions = value.get("$defs").cloned().unwrap_or(Value::Null); + sanitize_schema_node(value, &definitions); +} + +fn sanitize_schema_node(value: &mut Value, definitions: &Value) { + if let Some(object) = value.as_object_mut() { + let property_names = closed_object_properties(object, definitions); + if let (Some(property_names), Some(default)) = ( + property_names, + object.get_mut("default").and_then(Value::as_object_mut), + ) { + default.retain(|name, _| property_names.contains(name)); + } + for child in object.values_mut() { + sanitize_schema_node(child, definitions); + } + } else if let Some(array) = value.as_array_mut() { + for child in array { + sanitize_schema_node(child, definitions); + } + } +} + +fn closed_object_properties( + object: &serde_json::Map, + definitions: &Value, +) -> Option> { + let closed_object = if object.get("additionalProperties") == Some(&Value::Bool(false)) { + Some(object) + } else { + object + .get("$ref") + .and_then(Value::as_str) + .and_then(|reference| reference.strip_prefix("#/$defs/")) + .and_then(|name| definitions.get(name)) + .and_then(Value::as_object) + .filter(|definition| { + definition.get("additionalProperties") == Some(&Value::Bool(false)) + }) + }?; + closed_object + .get("properties") + .and_then(Value::as_object) + .map(|properties| properties.keys().cloned().collect()) +} + +fn canonical_adapter_profile( + driver: &str, + name: &str, + profile: &T, +) -> Result { + let canonical_json = serde_json::to_value(profile) + .map_err(|error| AdapterProfileContractError::Contract(error.to_string()))?; + + let toml_value = toml::Value::try_from(profile) + .map_err(|error| AdapterProfileContractError::Contract(error.to_string()))?; + let mut canonical_toml = toml::to_string_pretty(&toml_value) + .map_err(|error| AdapterProfileContractError::Contract(error.to_string()))?; + if !canonical_toml.ends_with('\n') { + canonical_toml.push('\n'); + } + Ok(ValidatedAdapterProfile { + format: "lab.adapter-profile-validation.v2".to_owned(), + schema_version: ADAPTER_PROFILE_SCHEMA_VERSION.to_owned(), + compiler_version: env!("CARGO_PKG_VERSION").to_owned(), + name: name.to_owned(), + driver: driver.to_owned(), + sha256: sha256(canonical_toml.as_bytes()), + canonical_toml, + canonical_json, + }) +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct EmptyAdapterProfile {} + +fn empty_profile(driver: &str, name: &str) -> ValidatedAdapterProfile { + let canonical_toml = String::new(); + let sha256 = sha256(canonical_toml.as_bytes()); + ValidatedAdapterProfile { + format: "lab.adapter-profile-validation.v2".to_owned(), + schema_version: ADAPTER_PROFILE_SCHEMA_VERSION.to_owned(), + compiler_version: env!("CARGO_PKG_VERSION").to_owned(), + name: name.to_owned(), + driver: driver.to_owned(), + canonical_toml, + canonical_json: json!({}), + sha256, + } +} + +fn sha256(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn invalid(driver: &str, error: impl std::fmt::Display) -> AdapterProfileContractError { + AdapterProfileContractError::Invalid { + driver: driver.to_owned(), + message: error.to_string(), + } +} + +fn unknown_driver(found: &str) -> AdapterProfileContractError { + AdapterProfileContractError::UnknownDriver { + found: found.to_owned(), + known: KNOWN_ADAPTERS + .iter() + .map(|driver| format!("'{driver}'")) + .collect::>() + .join(", "), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_separates_semantic_capabilities_from_features() { + let catalog = adapter_catalog().unwrap(); + + assert_eq!(catalog.format, ADAPTER_CATALOG_FORMAT); + assert_eq!(catalog.adapters.len(), KNOWN_ADAPTERS.len()); + let star = catalog + .adapters + .iter() + .find(|adapter| adapter.id == "hamilton.star") + .unwrap(); + assert_eq!(star.capabilities, strings([LIQUID_HANDLING])); + assert!(star.features.contains("eight-channel")); + assert!(!star.capabilities.contains("eight-channel")); + assert!(star.control_modes.contains(ControlMode::Api.iri())); + assert!(star.accepted_run_formats.contains(STAR_RUN_FORMAT)); + assert!(star.services.lowering); + assert!(star.services.runtime); + + let simulator = catalog + .adapters + .iter() + .find(|adapter| adapter.id == "lab.simulator") + .unwrap(); + assert!(simulator.services.simulation); + assert!(!simulator.services.lowering); + assert!(!simulator.services.runtime); + assert!( + simulator + .accepted_run_formats + .contains(SIMULATION_RUN_FORMAT) + ); + assert_eq!( + simulator.capabilities, + strings([LIQUID_HANDLING, INCUBATION, ABSORBANCE_MEASUREMENT]) + ); + } + + #[test] + fn explicit_driver_selects_the_profile_schema() { + let wrong = validate_adapter_profile( + "hamilton.star", + "star-1", + "[target]\nbackend = \"opentrons.ot2\"\n", + ) + .unwrap_err() + .to_string(); + assert!(wrong.contains("hamilton.star"), "{wrong}"); + assert!(wrong.contains("target"), "{wrong}"); + + let flex = validate_adapter_profile("opentrons.flex", "flex-1", "").unwrap(); + assert_eq!(flex.driver, "opentrons.flex"); + assert!(flex.canonical_json.get("target").is_none()); + assert!(!flex.canonical_toml.contains("[target]")); + let flex_descriptor = adapter_catalog() + .unwrap() + .adapters + .into_iter() + .find(|adapter| adapter.id == "opentrons.flex") + .unwrap(); + assert!( + flex_descriptor.profile_schema["properties"] + .get("target") + .is_none() + ); + + let profile = validate_adapter_profile("inheco.odtc", "cycler-1", "").unwrap(); + assert_eq!(profile.driver, "inheco.odtc"); + assert_eq!(profile.canonical_json, json!({})); + assert_eq!(profile.sha256.len(), 64); + } + + #[test] + fn empty_profiles_reject_unknown_operational_configuration() { + let error = validate_adapter_profile( + "inheco.odtc", + "cycler-1", + "endpoint = \"192.0.2.10:8080\"\n", + ) + .unwrap_err() + .to_string(); + + assert!(error.contains("unknown field `endpoint`"), "{error}"); + } +} diff --git a/crates/lab-compiler/src/backend/constraints.rs b/crates/lab-compiler/src/backend/constraints.rs index 8670e53..bf7a135 100644 --- a/crates/lab-compiler/src/backend/constraints.rs +++ b/crates/lab-compiler/src/backend/constraints.rs @@ -1,44 +1,42 @@ //! Backend-independent vocabulary for reporting lowering constraints. //! -//! A specialization supplies target, operation, resource, and parameter names; -//! the compiler infrastructure owns only the general shapes of constraint -//! failure. +//! An adapter specialization supplies implementation, operation, resource, and parameter names; the compiler infrastructure owns only the general shapes of constraint failure. use thiserror::Error; #[derive(Clone, Debug, Error, PartialEq, Eq)] -pub enum TargetConstraintError { +pub enum AdapterConstraintError { #[error( - "target '{target}' does not support the operation sequence for '{subject}': expected {expected:?}, found {found:?}" + "adapter '{adapter}' does not support the operation sequence for '{subject}': expected {expected:?}, found {found:?}" )] UnsupportedOperationSequence { - target: String, + adapter: String, subject: String, expected: Vec, found: Vec, }, #[error( - "target '{target}' requires parameter '{parameter}' for '{subject}' to be between {minimum} and {maximum}, found {found}" + "adapter '{adapter}' requires parameter '{parameter}' for '{subject}' to be between {minimum} and {maximum}, found {found}" )] ParameterOutOfRange { - target: String, + adapter: String, subject: String, parameter: String, minimum: u64, maximum: u64, found: u64, }, - #[error("target '{target}' requires uniform values for {parameters:?} across '{subject}'")] + #[error("adapter '{adapter}' requires uniform values for {parameters:?} across '{subject}'")] NonUniformParameters { - target: String, + adapter: String, subject: String, parameters: Vec, }, #[error( - "target '{target}' capacity exceeded during '{operation}' for '{subject}' resource '{resource}': required {required} {unit}, capacity {capacity} {unit}" + "adapter '{adapter}' capacity exceeded during '{operation}' for '{subject}' resource '{resource}': required {required} {unit}, capacity {capacity} {unit}" )] CapacityExceeded { - target: String, + adapter: String, operation: String, subject: String, resource: String, @@ -50,12 +48,12 @@ pub enum TargetConstraintError { #[cfg(test)] mod tests { - use crate::backend::constraints::TargetConstraintError; + use crate::backend::constraints::AdapterConstraintError; #[test] fn capacity_diagnostics_do_not_encode_a_protocol() { - let error = TargetConstraintError::CapacityExceeded { - target: "example_target".into(), + let error = AdapterConstraintError::CapacityExceeded { + adapter: "example_adapter".into(), operation: "example_operation".into(), subject: "example_subject".into(), resource: "reaction_volume".into(), @@ -65,7 +63,7 @@ mod tests { }; assert_eq!( error.to_string(), - "target 'example_target' capacity exceeded during 'example_operation' for 'example_subject' resource 'reaction_volume': required 24 uL, capacity 20 uL" + "adapter 'example_adapter' capacity exceeded during 'example_operation' for 'example_subject' resource 'reaction_volume': required 24 uL, capacity 20 uL" ); } } diff --git a/crates/lab-compiler/src/backend/descriptor.rs b/crates/lab-compiler/src/backend/descriptor.rs deleted file mode 100644 index 9a350df..0000000 --- a/crates/lab-compiler/src/backend/descriptor.rs +++ /dev/null @@ -1,20 +0,0 @@ -use std::collections::BTreeSet; - -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct BackendDescriptor { - pub id: String, - pub display_name: String, - pub manufacturer: Option, - pub targets: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct BackendTarget { - pub id: String, - pub display_name: String, - /// Stable capability identifiers used for discovery and preflight. The - /// backend remains responsible for detailed constraint validation. - pub capabilities: BTreeSet, -} diff --git a/crates/lab-compiler/src/backend/document.rs b/crates/lab-compiler/src/backend/document.rs index 16e39c7..a48554a 100644 --- a/crates/lab-compiler/src/backend/document.rs +++ b/crates/lab-compiler/src/backend/document.rs @@ -19,9 +19,8 @@ pub(in crate::backend) struct DocMeta { /// The line under the title that says what kind of document this is, /// e.g. "Operator manual for one robot session". pub subtitle: String, - /// Target profile name, e.g. "hamilton-star". Empty when the document - /// is not tied to one profile. - pub target: String, + /// Exact adapter-profile label. Empty when the document is implementation-independent. + pub adapter_profile: String, /// Instrument label, e.g. "Opentrons OT-2". pub instrument: String, } @@ -30,13 +29,13 @@ impl DocMeta { pub fn new( title: impl Into, subtitle: impl Into, - target: impl Into, + adapter_profile: impl Into, instrument: impl Into, ) -> Self { Self { title: title.into(), subtitle: subtitle.into(), - target: target.into(), + adapter_profile: adapter_profile.into(), instrument: instrument.into(), } } @@ -60,7 +59,6 @@ pub(in crate::backend) enum Block { /// disclaimer. Notice(Vec), Bullets(Vec>), - Numbered(Vec>), Table { columns: Vec, rows: Vec>>, @@ -166,11 +164,6 @@ impl Doc { .push(Block::Bullets(items.into_iter().collect())); } - pub fn numbered(&mut self, items: impl IntoIterator>) { - self.blocks - .push(Block::Numbered(items.into_iter().collect())); - } - /// A table with no rows is dropped: a bare header rule carries no /// information and reads as a rendering fault on the page. pub fn table( diff --git a/crates/lab-compiler/src/backend/error.rs b/crates/lab-compiler/src/backend/error.rs index 6ca8de3..81356f7 100644 --- a/crates/lab-compiler/src/backend/error.rs +++ b/crates/lab-compiler/src/backend/error.rs @@ -2,7 +2,7 @@ use thiserror::Error; -use crate::backend::TargetConstraintError; +use crate::backend::AdapterConstraintError; /// A failure from robot-neutral planning. Each backend converts this into its /// own planning error, so the rendered message keeps that backend's identity @@ -10,13 +10,13 @@ use crate::backend::TargetConstraintError; #[derive(Debug, Error, PartialEq, Eq)] pub(in crate::backend) enum PlanningError { #[error(transparent)] - Constraint(Box), + Constraint(Box), #[error("{0}")] InvalidProtocol(String), } -impl From for PlanningError { - fn from(error: TargetConstraintError) -> Self { +impl From for PlanningError { + fn from(error: AdapterConstraintError) -> Self { Self::Constraint(Box::new(error)) } } diff --git a/crates/lab-compiler/src/backend/graph.rs b/crates/lab-compiler/src/backend/graph.rs index 63e688b..4c2ec00 100644 --- a/crates/lab-compiler/src/backend/graph.rs +++ b/crates/lab-compiler/src/backend/graph.rs @@ -1,4 +1,4 @@ -//! Projection of Protocol provenance into target-independent build scheduling data. +//! Projection of Protocol provenance into facility-independent build scheduling data. use std::collections::BTreeSet; diff --git a/crates/lab-compiler/src/backend/hamilton/star/backend.rs b/crates/lab-compiler/src/backend/hamilton/star/backend.rs index dd6fe3f..7e44749 100644 --- a/crates/lab-compiler/src/backend/hamilton/star/backend.rs +++ b/crates/lab-compiler/src/backend/hamilton/star/backend.rs @@ -1,31 +1,29 @@ //! STAR implementation of the compiler backend contracts. -use std::collections::BTreeSet; - use thiserror::Error; -use crate::backend::{Backend, BackendDescriptor, BackendEmitter, BackendTarget}; +use crate::backend::{Backend, BackendEmitter}; use crate::{ArtifactBundle, ProtocolLairProgram}; use crate::backend::hamilton::star::emit::emit_program; use crate::backend::hamilton::star::plan::{ StarEmissionError, StarExecutionPlan, StarPlanningError, plan_build, }; -use crate::backend::hamilton::star::profile::StarTargetProfile; +use crate::backend::hamilton::star::profile::StarAdapterProfile; /// The STAR backend bound to one bench. Planning reads every carrier, /// labware, and tip decision from the profile it carries. #[derive(Clone, Debug, Default)] pub struct StarBackend { - profile: StarTargetProfile, + profile: StarAdapterProfile, } impl StarBackend { - pub fn new(profile: StarTargetProfile) -> Self { + pub fn new(profile: StarAdapterProfile) -> Self { Self { profile } } - pub fn profile(&self) -> &StarTargetProfile { + pub fn profile(&self) -> &StarAdapterProfile { &self.profile } } @@ -40,24 +38,6 @@ impl Backend for StarBackend { type Program = StarExecutionPlan; type Error = StarCompileError; - fn descriptor(&self) -> BackendDescriptor { - BackendDescriptor { - id: "hamilton".into(), - display_name: "Hamilton firmware protocol".into(), - manufacturer: Some("Hamilton".into()), - targets: vec![BackendTarget { - id: "star".into(), - display_name: "Hamilton STAR/STARlet".into(), - capabilities: BTreeSet::from([ - "liquid_transfer".into(), - "eight_channel".into(), - "firmware_protocol".into(), - "live_run".into(), - ]), - }], - } - } - fn compile(&self, protocol: &ProtocolLairProgram) -> Result { Ok(plan_build(protocol, &self.profile)?) } @@ -83,8 +63,6 @@ mod tests { let protocol = golden_gate_protocol(); let backend = StarBackend::default(); let program = backend.compile(&protocol).unwrap(); - assert_eq!(backend.descriptor().id, "hamilton"); - assert_eq!(backend.descriptor().targets[0].id, "star"); assert_eq!(program.assemblies.len(), 2); // One plasmid feeds two chassis, so four strains come from two // assemblies rather than one strain per assembly. diff --git a/crates/lab-compiler/src/backend/hamilton/star/emit/manual.rs b/crates/lab-compiler/src/backend/hamilton/star/emit/manual.rs index c847de0..c3dbae6 100644 --- a/crates/lab-compiler/src/backend/hamilton/star/emit/manual.rs +++ b/crates/lab-compiler/src/backend/hamilton/star/emit/manual.rs @@ -7,7 +7,7 @@ use crate::backend::document::{Block, Column, Doc, DocMeta, bold, code, text}; use crate::backend::hamilton::star::catalog; use crate::backend::hamilton::star::plan::StarExecutionPlan; -use crate::backend::hamilton::star::profile::StarTargetProfile; +use crate::backend::hamilton::star::profile::StarAdapterProfile; fn fragment() -> Doc { Doc::new(DocMeta::new("", "", "", "")) @@ -15,12 +15,12 @@ fn fragment() -> Doc { /// The machine, the runner, and the deck: everything that holds for any run /// compiled against this bench profile. -pub(in crate::backend) fn bench_blocks(profile: &StarTargetProfile) -> Vec { +pub(in crate::backend) fn bench_blocks(profile: &StarAdapterProfile) -> Vec { let mut doc = fragment(); doc.para([ text(format!( "Compiled for bench {} ({} deck, {} channels). Robot steps live in the ", - profile.target.name, + profile.name, profile.machine.variant.name(), profile.machine.channels, )), @@ -229,7 +229,7 @@ pub(in crate::backend) fn render_manual_protocol(plan: &StarExecutionPlan) -> Do let mut doc = Doc::new(DocMeta::new( "Hamilton STAR run", "Operator instructions for one machine session", - &profile.target.name, + &profile.name, "Hamilton STAR", )); doc.notice([text( diff --git a/crates/lab-compiler/src/backend/hamilton/star/emit/mod.rs b/crates/lab-compiler/src/backend/hamilton/star/emit/mod.rs index fe4047b..c78cb73 100644 --- a/crates/lab-compiler/src/backend/hamilton/star/emit/mod.rs +++ b/crates/lab-compiler/src/backend/hamilton/star/emit/mod.rs @@ -17,7 +17,7 @@ use crate::backend::hamilton::star::emit::runs::render_run; use crate::backend::hamilton::star::plan::{ StarBuildError, StarEmissionError, StarExecutionPlan, plan_build, }; -use crate::backend::hamilton::star::profile::StarTargetProfile; +use crate::backend::hamilton::star::profile::StarAdapterProfile; use crate::backend::{markdown, typst}; pub use crate::backend::hamilton::star::emit::runs::RunStep; @@ -116,7 +116,7 @@ impl StarBundle { pub fn compile_build( protocol: &ProtocolLairProgram, - profile: &StarTargetProfile, + profile: &StarAdapterProfile, ) -> Result { Ok(StarBundle::from_plan(plan_build(protocol, profile)?)?) } diff --git a/crates/lab-compiler/src/backend/hamilton/star/mod.rs b/crates/lab-compiler/src/backend/hamilton/star/mod.rs index d67b7b2..2cf2f21 100644 --- a/crates/lab-compiler/src/backend/hamilton/star/mod.rs +++ b/crates/lab-compiler/src/backend/hamilton/star/mod.rs @@ -15,9 +15,7 @@ mod package; pub mod plan; pub mod profile; -/// This backend's identity. A target profile declares it, planning stamps it -/// into every execution plan and target-constraint error, and no other -/// spelling of it exists. +/// Stable adapter identity used by explicit Asset bindings, device plans, and adapter diagnostics. pub(in crate::backend::hamilton::star) const BACKEND: &str = "hamilton.star"; pub use crate::backend::hamilton::star::backend::{StarBackend, StarCompileError}; @@ -28,4 +26,4 @@ pub use crate::backend::hamilton::star::package::{ pub use crate::backend::hamilton::star::plan::{ ManualStep, StarBuildError, StarEmissionError, StarExecutionPlan, StarPlanningError, plan_build, }; -pub use crate::backend::hamilton::star::profile::{StarProfileError, StarTargetProfile}; +pub use crate::backend::hamilton::star::profile::{StarAdapterProfile, StarProfileError}; diff --git a/crates/lab-compiler/src/backend/hamilton/star/package.rs b/crates/lab-compiler/src/backend/hamilton/star/package.rs index e0a7a63..f2fb9c6 100644 --- a/crates/lab-compiler/src/backend/hamilton/star/package.rs +++ b/crates/lab-compiler/src/backend/hamilton/star/package.rs @@ -12,7 +12,7 @@ use crate::backend::hamilton::star::emit::StarBundle; use crate::backend::hamilton::star::plan::{ StarBuildError, plan_selected_build, protocol_build_graph, }; -use crate::backend::hamilton::star::profile::StarTargetProfile; +use crate::backend::hamilton::star::profile::StarAdapterProfile; use crate::backend::package::{render_full_build_instructions, render_report}; use crate::backend::typst; use crate::planning::{BuildInventory, DependencyBuildManifest}; @@ -60,12 +60,12 @@ pub enum StarDependencyBuildError { } /// Specialize a source-derived dependency graph into independently -/// executable STAR packages. Graph resolution itself is target-neutral; +/// executable STAR packages. Graph resolution itself is facility-independent; /// only the requirements projected into each node and the emitted batches /// are owned by this module. pub fn compile_dependency_build( protocol: &ProtocolLairProgram, - profile: &StarTargetProfile, + profile: &StarAdapterProfile, inventory: &BuildInventory, ) -> Result { let graph = @@ -106,7 +106,7 @@ pub fn compile_dependency_build( DocMeta::new( "Dependency report", "Artifact graph, wave schedule, and blockers", - &profile.target.name, + &profile.name, "Hamilton STAR", ), &manifest, @@ -154,7 +154,7 @@ pub fn compile_dependency_build( DocMeta::new( "Automated plasmid build", "Operator instructions for the full dependency-driven build", - &profile.target.name, + &profile.name, "Hamilton STAR", ), &manifest, diff --git a/crates/lab-compiler/src/backend/hamilton/star/plan/build.rs b/crates/lab-compiler/src/backend/hamilton/star/plan/build.rs index 41dea80..fecd743 100644 --- a/crates/lab-compiler/src/backend/hamilton/star/plan/build.rs +++ b/crates/lab-compiler/src/backend/hamilton/star/plan/build.rs @@ -30,7 +30,7 @@ use crate::backend::hamilton::star::plan::liquids::{ AGAR_SPOT_HEIGHT_MM, DeckIndex, LiquidState, PLATE_DEAD_VOLUME_UL, TROUGH_DEAD_VOLUME_UL, TUBE_DEAD_VOLUME_UL, }; -use crate::backend::hamilton::star::profile::StarTargetProfile; +use crate::backend::hamilton::star::profile::StarAdapterProfile; use crate::backend::resources::{ PlateAllocator, assembly_source_keys, assign_source_wells, plate_wells, transformation_source_keys, @@ -42,14 +42,14 @@ use crate::backend::trace::{AssemblyTrace, ProtocolTraces, StrainTrace, analyze_ /// by the run and Markdown emitters. pub fn plan_build( protocol: &ProtocolLairProgram, - profile: &StarTargetProfile, + profile: &StarAdapterProfile, ) -> Result { plan_selected_build(protocol, profile, None) } pub(in crate::backend) fn plan_selected_build( protocol: &ProtocolLairProgram, - profile: &StarTargetProfile, + profile: &StarAdapterProfile, selected_artifacts: Option<&BTreeSet>, ) -> Result { profile.validate()?; @@ -101,8 +101,8 @@ pub(in crate::backend) fn plan_selected_build( let (runs, tip_usage) = choreograph_program(profile, &deck, &science, &mut liquids)?; Ok(StarExecutionPlan { - schema_version: "lab.automation.v0".into(), - target: BACKEND.into(), + schema_version: "lab.automation.v1".into(), + adapter: BACKEND.into(), deck: profile.clone(), assembly_source_wells, transformation_source_wells, @@ -213,7 +213,7 @@ fn build_assembly_plans( fn allocate_dna_wells( traces: &[StrainTrace], context: &Context, - profile: &StarTargetProfile, + profile: &StarAdapterProfile, ) -> Result, StarPlanningError> { let carried = traces .iter() @@ -245,7 +245,7 @@ fn build_strain_plans( context: &Context, reaction_plate: &[String], dna_source_wells: &BTreeMap, - profile: &StarTargetProfile, + profile: &StarAdapterProfile, ) -> Result, StarPlanningError> { let mut culture_cursor = 0; let mut dilutions = PlateAllocator::new( @@ -371,7 +371,7 @@ fn assign_stage_source_wells( /// Lowers the whole program into runs. Deterministic: called twice, once to /// discover consumption and once over the seeded bench. fn choreograph_program( - profile: &StarTargetProfile, + profile: &StarAdapterProfile, deck: &DeckIndex, science: &Science<'_>, liquids: &mut LiquidState, @@ -893,8 +893,8 @@ fn compute_fills( let load = consumed + dead; let (_, working_ul, _) = deck.vessel(&location.resource); if load > working_ul { - return Err(crate::backend::TargetConstraintError::CapacityExceeded { - target: BACKEND.into(), + return Err(crate::backend::AdapterConstraintError::CapacityExceeded { + adapter: BACKEND.into(), operation: "source_loading".into(), subject: key.clone(), resource: location.resource.clone(), @@ -953,10 +953,10 @@ mod tests { #[test] fn allocates_both_stages_against_the_reference_bench() { let protocol = golden_gate_protocol(); - let plan = plan_build(&protocol, &StarTargetProfile::default()) + let plan = plan_build(&protocol, &StarAdapterProfile::default()) .expect("the example compiles for the reference bench"); - assert_eq!(plan.target, "hamilton.star"); - assert_eq!(plan.schema_version, "lab.automation.v0"); + assert_eq!(plan.adapter, "hamilton.star"); + assert_eq!(plan.schema_version, "lab.automation.v1"); assert_eq!(plan.assemblies.len(), 2, "two plasmids are assembled"); assert_eq!(plan.strains.len(), 4, "one plasmid feeds two chassis"); assert_eq!(plan.assemblies[0].assembly_wells, ["A1"]); @@ -976,7 +976,7 @@ mod tests { fn source_fills_cover_consumption_plus_dead_volume() { let protocol = golden_gate_protocol(); let plan = - plan_build(&protocol, &StarTargetProfile::default()).expect("the example compiles"); + plan_build(&protocol, &StarAdapterProfile::default()).expect("the example compiles"); let water = plan .source_fills .iter() @@ -996,7 +996,7 @@ mod tests { fn every_run_returns_its_tips_to_the_waste() { let protocol = golden_gate_protocol(); let plan = - plan_build(&protocol, &StarTargetProfile::default()).expect("the example compiles"); + plan_build(&protocol, &StarAdapterProfile::default()).expect("the example compiles"); for run in &plan.runs { let picked: usize = run .operations @@ -1026,7 +1026,7 @@ mod tests { fn tip_usage_stays_within_every_rack() { let protocol = golden_gate_protocol(); let plan = - plan_build(&protocol, &StarTargetProfile::default()).expect("the example compiles"); + plan_build(&protocol, &StarAdapterProfile::default()).expect("the example compiles"); for (resource, used) in &plan.tip_usage { assert!( *used <= 96, @@ -1038,7 +1038,7 @@ mod tests { #[test] fn an_agar_capacity_that_contradicts_the_catalog_is_a_profile_error() { let protocol = golden_gate_protocol(); - let mut wrong = StarTargetProfile::default(); + let mut wrong = StarAdapterProfile::default(); wrong.stages.plating.agar_plate.capacity = 15; let error = plan_build(&protocol, &wrong).expect_err("the catalog plate holds 96 wells, not 15"); diff --git a/crates/lab-compiler/src/backend/hamilton/star/plan/choreograph.rs b/crates/lab-compiler/src/backend/hamilton/star/plan/choreograph.rs index 42a51fe..9aa753e 100644 --- a/crates/lab-compiler/src/backend/hamilton/star/plan/choreograph.rs +++ b/crates/lab-compiler/src/backend/hamilton/star/plan/choreograph.rs @@ -16,7 +16,7 @@ use hamilton_star::catalog::{CorrectionCurve, TipType}; -use crate::backend::TargetConstraintError; +use crate::backend::AdapterConstraintError; use crate::backend::hamilton::star::BACKEND; use crate::backend::hamilton::star::catalog::{LabwareDefinition, parse_well}; use crate::backend::hamilton::star::plan::error::StarPlanningError; @@ -24,7 +24,7 @@ use crate::backend::hamilton::star::plan::execution::{ ChannelLiquid, StarOperation, StarWell, TipClass, TipPickupPosition, }; use crate::backend::hamilton::star::plan::liquids::{DeckIndex, LiquidState, wire_mm, wire_ul}; -use crate::backend::hamilton::star::profile::StarTargetProfile; +use crate::backend::hamilton::star::profile::StarAdapterProfile; use crate::backend::resources::plate_wells; /// Mix applied after assembly reagent additions: 3 × 15 µL. @@ -116,8 +116,8 @@ impl TipFeeder { fn take(&mut self, count: usize) -> Result>, StarPlanningError> { let total = self.rack_count * self.capacity; if self.cursor + count > total { - return Err(TargetConstraintError::CapacityExceeded { - target: BACKEND.into(), + return Err(AdapterConstraintError::CapacityExceeded { + adapter: BACKEND.into(), operation: "tip_pickup".into(), subject: "automation_batch".into(), resource: self.prefix.clone(), @@ -163,7 +163,7 @@ impl TipFeeder { /// Builds one run's operation list. pub struct RunBuilder<'a> { - profile: &'a StarTargetProfile, + profile: &'a StarAdapterProfile, deck: &'a DeckIndex, liquids: &'a mut LiquidState, small: Option, @@ -200,7 +200,7 @@ impl Curves { impl<'a> RunBuilder<'a> { pub fn new( - profile: &'a StarTargetProfile, + profile: &'a StarAdapterProfile, deck: &'a DeckIndex, liquids: &'a mut LiquidState, small: Option, @@ -555,7 +555,7 @@ fn consecutive_in_column(previous: &StarWell, next: &StarWell) -> bool { mod tests { use super::*; use crate::backend::hamilton::star::plan::liquids::{DeckIndex, LiquidState}; - use crate::backend::hamilton::star::profile::StarTargetProfile; + use crate::backend::hamilton::star::profile::StarAdapterProfile; fn feeder(deck: &DeckIndex) -> TipFeeder { TipFeeder::new("assembly_small_tips", deck, 1, 96) @@ -563,7 +563,7 @@ mod tests { #[test] fn a_full_column_of_aligned_transfers_batches_to_one_eight_channel_op() { - let profile = StarTargetProfile::default(); + let profile = StarAdapterProfile::default(); let deck = DeckIndex::build(&profile).expect("the reference bench resolves"); let mut liquids = LiquidState::new(); let transfers: Vec = (0..8) @@ -597,7 +597,7 @@ mod tests { #[test] fn a_lone_transfer_runs_single_channel() { - let profile = StarTargetProfile::default(); + let profile = StarAdapterProfile::default(); let deck = DeckIndex::build(&profile).expect("the reference bench resolves"); let mut liquids = LiquidState::new(); let transfers = [Transfer::new( @@ -621,7 +621,7 @@ mod tests { #[test] fn multi_dispense_chunks_split_at_the_tip_working_volume() { - let profile = StarTargetProfile::default(); + let profile = StarAdapterProfile::default(); let deck = DeckIndex::build(&profile).expect("the reference bench resolves"); let mut liquids = LiquidState::new(); // 4 × 20 µL of water correct to ~4 × 23.2 µL; a 60 µL working @@ -670,7 +670,7 @@ mod tests { #[test] fn tip_exhaustion_names_the_rack_resource() { - let profile = StarTargetProfile::default(); + let profile = StarAdapterProfile::default(); let deck = DeckIndex::build(&profile).expect("the reference bench resolves"); let mut feeder = TipFeeder::new("assembly_small_tips", &deck, 1, 96); feeder.take(96).expect("the rack holds 96 tips"); @@ -684,7 +684,7 @@ mod tests { #[test] fn multi_channel_pickups_split_at_rack_column_boundaries() { - let profile = StarTargetProfile::default(); + let profile = StarAdapterProfile::default(); let deck = DeckIndex::build(&profile).expect("the reference bench resolves"); let mut feeder = TipFeeder::new("assembly_small_tips", &deck, 1, 96); feeder.take(6).expect("six tips leave two in the column"); diff --git a/crates/lab-compiler/src/backend/hamilton/star/plan/constraints.rs b/crates/lab-compiler/src/backend/hamilton/star/plan/constraints.rs index 411cfba..27d47ce 100644 --- a/crates/lab-compiler/src/backend/hamilton/star/plan/constraints.rs +++ b/crates/lab-compiler/src/backend/hamilton/star/plan/constraints.rs @@ -3,7 +3,7 @@ //! vessel-volume checks a deck without modules needs: everything a well //! accumulates must fit the labware planning placed it in. -use crate::backend::TargetConstraintError; +use crate::backend::AdapterConstraintError; use crate::backend::trace::{AssemblyTrace, StrainTrace}; use crate::backend::hamilton::star::BACKEND; @@ -28,8 +28,8 @@ pub(super) fn validate_assembly_constraints( + trace.chemistry(context, "enzyme_volume_ul") + trace.chemistry(context, "part_volume_ul") * dna_pieces; if required_ul > reaction_volume_ul { - return Err(TargetConstraintError::CapacityExceeded { - target: BACKEND.into(), + return Err(AdapterConstraintError::CapacityExceeded { + adapter: BACKEND.into(), operation: "assembly".into(), subject: artifact, resource: "reaction_volume".into(), @@ -40,8 +40,8 @@ pub(super) fn validate_assembly_constraints( .into()); } if f64::from(reaction_volume_ul) > reaction_well_capacity_ul { - return Err(TargetConstraintError::CapacityExceeded { - target: BACKEND.into(), + return Err(AdapterConstraintError::CapacityExceeded { + adapter: BACKEND.into(), operation: "assembly".into(), subject: artifact, resource: "reaction_plate_well".into(), @@ -79,8 +79,8 @@ pub(super) fn validate_strain_constraints( + trace.chemistry(context, "dna_volume_ul") * plasmids + trace.chemistry(context, "recovery_volume_ul"); if f64::from(culture_ul) > reaction_well_capacity_ul { - return Err(TargetConstraintError::CapacityExceeded { - target: BACKEND.into(), + return Err(AdapterConstraintError::CapacityExceeded { + adapter: BACKEND.into(), operation: "transformation".into(), subject: artifact, resource: "reaction_plate_well".into(), @@ -100,8 +100,8 @@ fn require_range( maximum: u8, ) -> Result<(), StarPlanningError> { if !(1..=maximum).contains(&value) { - return Err(TargetConstraintError::ParameterOutOfRange { - target: BACKEND.into(), + return Err(AdapterConstraintError::ParameterOutOfRange { + adapter: BACKEND.into(), subject: artifact.to_owned(), parameter: parameter.into(), minimum: 1, @@ -132,8 +132,8 @@ pub(super) fn validate_uniform_batch_settings( trace.serial_dilutions(context), ) != expected }) { - Err(TargetConstraintError::NonUniformParameters { - target: BACKEND.into(), + Err(AdapterConstraintError::NonUniformParameters { + adapter: BACKEND.into(), subject: "automation_batch".into(), parameters: vec![ "transformation_replicates".into(), @@ -153,8 +153,8 @@ pub(super) fn plate_capacity_error( required: usize, capacity: usize, ) -> StarPlanningError { - TargetConstraintError::CapacityExceeded { - target: BACKEND.into(), + AdapterConstraintError::CapacityExceeded { + adapter: BACKEND.into(), operation: stage.into(), subject: "automation_batch".into(), resource: resource.into(), diff --git a/crates/lab-compiler/src/backend/hamilton/star/plan/error.rs b/crates/lab-compiler/src/backend/hamilton/star/plan/error.rs index 4e8fefd..f5028b3 100644 --- a/crates/lab-compiler/src/backend/hamilton/star/plan/error.rs +++ b/crates/lab-compiler/src/backend/hamilton/star/plan/error.rs @@ -1,22 +1,22 @@ use thiserror::Error; use crate::ArtifactError; -use crate::backend::TargetConstraintError; +use crate::backend::AdapterConstraintError; use crate::backend::error::PlanningError; use crate::backend::hamilton::star::profile::StarProfileError; #[derive(Debug, Error, PartialEq)] pub enum StarPlanningError { #[error(transparent)] - Constraint(Box), - #[error("invalid target-selected Protocol LAIR: {0}")] + Constraint(Box), + #[error("invalid method-selected Protocol LAIR: {0}")] InvalidProtocol(String), #[error(transparent)] Profile(#[from] StarProfileError), } -impl From for StarPlanningError { - fn from(error: TargetConstraintError) -> Self { +impl From for StarPlanningError { + fn from(error: AdapterConstraintError) -> Self { Self::Constraint(Box::new(error)) } } diff --git a/crates/lab-compiler/src/backend/hamilton/star/plan/execution.rs b/crates/lab-compiler/src/backend/hamilton/star/plan/execution.rs index 3559cf6..3bf76aa 100644 --- a/crates/lab-compiler/src/backend/hamilton/star/plan/execution.rs +++ b/crates/lab-compiler/src/backend/hamilton/star/plan/execution.rs @@ -6,7 +6,7 @@ use std::collections::BTreeMap; use serde::Serialize; -use crate::backend::hamilton::star::profile::StarTargetProfile; +use crate::backend::hamilton::star::profile::StarAdapterProfile; /// A well on a named plan resource. Resource keys are stable strings the /// deck summary and emitters share: `source_rack`, `reaction_plate`, @@ -33,12 +33,10 @@ impl StarWell { #[derive(Clone, Debug, PartialEq, Serialize)] pub struct StarExecutionPlan { pub schema_version: String, - /// The backend that produced this plan, spelled exactly as a target - /// profile declares it. - pub target: String, - /// The bench this plan was allocated against. Emission reads every - /// carrier, site, and labware decision from here. - pub deck: StarTargetProfile, + /// The explicit adapter implementation that produced this device plan. + pub adapter: String, + /// Checked implementation configuration for the allocated Asset binding. + pub deck: StarAdapterProfile, /// Source-rack well for each assembly-stage reagent, DNA, and enzyme /// key. pub assembly_source_wells: BTreeMap, @@ -156,9 +154,9 @@ pub struct StarRunPlan { pub title: String, pub operations: Vec, pub manual_after: Vec, - /// The thermal programs behind this run's manual steps, structured so a - /// workcell can assign them to a thermocycler station. On a bare STAR - /// target the operator prose in `manual_after` is the whole story, so + /// The thermal programs behind this run's manual steps, structured for + /// projection into separate thermocycler documents. On a standalone STAR + /// adapter the operator prose in `manual_after` is the whole story, so /// these never reach the serialized manifest. #[serde(skip)] pub thermal_after: Vec, @@ -166,8 +164,8 @@ pub struct StarRunPlan { /// A thermal program a run needs after its liquid handling. Each /// requirement shadows one step of `manual_after` (named by -/// `fallback_index`): a workcell with a thermocycler station executes the -/// profile and drops the prose; anything else keeps the prose verbatim. +/// `fallback_index`) so a facility plan can replace the prose with an exact +/// thermocycler binding. #[derive(Clone, Debug, PartialEq)] pub struct ThermalRequirement { /// Stable identity within the run, e.g. `assembly_thermocycle`. diff --git a/crates/lab-compiler/src/backend/hamilton/star/plan/liquids.rs b/crates/lab-compiler/src/backend/hamilton/star/plan/liquids.rs index 2a49525..8f216c0 100644 --- a/crates/lab-compiler/src/backend/hamilton/star/plan/liquids.rs +++ b/crates/lab-compiler/src/backend/hamilton/star/plan/liquids.rs @@ -21,7 +21,7 @@ use std::collections::BTreeMap; use crate::backend::hamilton::star::catalog::{DeckPosition, HeightModel}; use crate::backend::hamilton::star::plan::error::StarPlanningError; use crate::backend::hamilton::star::plan::execution::StarWell; -use crate::backend::hamilton::star::profile::{ResolvedSite, StarProfileError, StarTargetProfile}; +use crate::backend::hamilton::star::profile::{ResolvedSite, StarAdapterProfile, StarProfileError}; /// Aspiration depth below the tracked liquid surface, mm. pub const IMMERSION_DEPTH_MM: f64 = 2.0; @@ -58,7 +58,7 @@ pub struct DeckIndex { impl DeckIndex { /// Resolves every deck and stage resource of a validated profile. - pub fn build(profile: &StarTargetProfile) -> Result { + pub fn build(profile: &StarAdapterProfile) -> Result { let mut resources = BTreeMap::new(); let mut place = |key: String, site: Result| { site.map(|site| { @@ -265,10 +265,10 @@ impl LiquidState { #[cfg(test)] mod tests { use super::*; - use crate::backend::hamilton::star::profile::StarTargetProfile; + use crate::backend::hamilton::star::profile::StarAdapterProfile; fn deck() -> DeckIndex { - DeckIndex::build(&StarTargetProfile::default()).expect("the reference bench resolves") + DeckIndex::build(&StarAdapterProfile::default()).expect("the reference bench resolves") } #[test] diff --git a/crates/lab-compiler/src/backend/hamilton/star/profile.rs b/crates/lab-compiler/src/backend/hamilton/star/profile.rs index fa58684..ef999e8 100644 --- a/crates/lab-compiler/src/backend/hamilton/star/profile.rs +++ b/crates/lab-compiler/src/backend/hamilton/star/profile.rs @@ -1,16 +1,8 @@ -//! Site configuration for one Hamilton STAR/STARlet bench. +//! Operational configuration for the Hamilton STAR adapter. //! -//! A profile describes the laboratory, not the science: which catalog -//! carriers sit on which rails, which labware sits on which carrier site, -//! and where each build stage draws tips and liquid from. Two laboratories -//! running the same Lab program supply different profiles; neither program -//! changes. +//! Facility allocation has already selected an exact Asset before this profile is read. The profile contains only checked configuration the implementation still needs to produce and execute reviewed firmware frames. It cannot select a facility Asset or another adapter. //! -//! Every field has a default matching the reference bench, so a profile -//! states only what differs. Unknown keys are rejected, because a -//! misspelled site silently falling back to a default is how a protocol -//! ends up aspirating from the wrong place. Labware sites are addressed as -//! `"/"` with 1-based site numbers. +//! Every field has a default matching the reference implementation configuration, so a profile states only what differs. Unknown keys are rejected, because a misspelled site silently falling back to a default is how a protocol ends up aspirating from the wrong place. Labware sites are addressed as `"/"` with 1-based site numbers. use std::collections::BTreeMap; @@ -20,7 +12,6 @@ use thiserror::Error; pub use crate::backend::profile::{MediaRack, Plates, TipRacks}; -use crate::backend::hamilton::star::BACKEND; use crate::backend::hamilton::star::catalog::{ self, CarrierDefinition, DeckPosition, LabwareDefinition, }; @@ -28,13 +19,8 @@ use crate::backend::hamilton::star::catalog::{ /// The error raised when a profile cannot describe a workable bench. #[derive(Debug, Error, PartialEq)] pub enum StarProfileError { - #[error("failed to parse target profile TOML: {0}")] + #[error("failed to parse STAR adapter profile TOML: {0}")] Toml(String), - #[error("target profile declares backend '{found}', but this profile schema is '{expected}'")] - WrongBackend { - expected: &'static str, - found: String, - }, #[error("machine variant '{found}' is unknown; this backend knows 'star' and 'starlet'")] UnknownVariant { found: String }, #[error( @@ -149,27 +135,6 @@ impl MachineVariant { } } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct TargetMetadata { - /// The bench this profile describes, named by whoever loaded it: a - /// profile is selected as `targets/.toml`, so the file does not - /// repeat its own name and cannot disagree with it. - #[serde(skip_deserializing, default = "default_bench_name")] - pub name: String, - #[serde(default = "default_backend")] - pub backend: String, -} - -impl Default for TargetMetadata { - fn default() -> Self { - Self { - name: default_bench_name(), - backend: default_backend(), - } - } -} - #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Machine { @@ -350,12 +315,14 @@ impl Default for RunOptions { } } -/// The complete STAR site configuration consumed by planning and emission. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +/// The complete STAR implementation configuration consumed by planning and emission. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] -pub struct StarTargetProfile { - #[serde(default)] - pub target: TargetMetadata, +pub struct StarAdapterProfile { + /// File-stem label supplied by the exact Asset binding. It is review metadata, not profile input. + #[serde(skip)] + #[schemars(skip)] + pub name: String, #[serde(default)] pub machine: Machine, #[serde(default)] @@ -366,6 +333,18 @@ pub struct StarTargetProfile { pub run: RunOptions, } +impl Default for StarAdapterProfile { + fn default() -> Self { + Self { + name: "hamilton.star".to_owned(), + machine: Machine::default(), + deck: StarDeck::default(), + stages: StarStages::default(), + run: RunOptions::default(), + } + } +} + /// A site address resolved against the catalog: everything needed to place /// wells in deck millimeters. #[derive(Clone, Copy, Debug, PartialEq)] @@ -384,24 +363,17 @@ impl ResolvedSite { } } -impl StarTargetProfile { - /// Load the profile named `name`. The name is the profile's filename - /// under `targets/`, supplied by whoever resolved it. +impl StarAdapterProfile { + /// Load operational configuration for one exact Asset binding. pub fn parse(name: &str, text: &str) -> Result { let mut profile: Self = toml::from_str(text).map_err(|error| StarProfileError::Toml(error.to_string()))?; - profile.target.name = name.to_owned(); + profile.name = name.to_owned(); profile.validate()?; Ok(profile) } pub fn validate(&self) -> Result<(), StarProfileError> { - if self.target.backend != BACKEND { - return Err(StarProfileError::WrongBackend { - expected: BACKEND, - found: self.target.backend.clone(), - }); - } if self.machine.channels != 8 { return Err(StarProfileError::UnsupportedChannels { found: self.machine.channels, @@ -637,14 +609,6 @@ fn known_labware() -> String { .join(", ") } -fn default_bench_name() -> String { - "hamilton-star".into() -} - -fn default_backend() -> String { - BACKEND.into() -} - fn default_variant() -> MachineVariant { MachineVariant::Starlet } @@ -797,7 +761,7 @@ mod tests { #[test] fn the_reference_bench_validates() { - let profile = StarTargetProfile::default(); + let profile = StarAdapterProfile::default(); profile .validate() .expect("the defaults describe a coherent bench"); @@ -805,10 +769,9 @@ mod tests { #[test] fn a_minimal_profile_parses_with_defaults() { - let profile = - StarTargetProfile::parse("bench-1", "[target]\nbackend = \"hamilton.star\"\n") - .expect("a profile stating only its backend takes every default"); - assert_eq!(profile.target.name, "bench-1", "the loader names the bench"); + let profile = StarAdapterProfile::parse("star-runtime", "") + .expect("an empty profile takes every checked implementation default"); + assert_eq!(profile.name, "star-runtime"); assert_eq!( profile.machine.variant, MachineVariant::Starlet, @@ -817,22 +780,16 @@ mod tests { } #[test] - fn the_wrong_backend_is_rejected() { - let error = StarTargetProfile::parse("bench", "[target]\nbackend = \"opentrons.flex\"\n") - .expect_err("a Flex profile cannot describe a STAR"); - assert_eq!( - error, - StarProfileError::WrongBackend { - expected: "hamilton.star", - found: "opentrons.flex".into() - }, - "the error names both spellings" - ); + fn an_embedded_target_or_adapter_selector_is_rejected() { + let error = + StarAdapterProfile::parse("star-runtime", "[target]\nbackend = \"opentrons.flex\"\n") + .expect_err("only the exact Asset binding may select an adapter"); + assert!(error.to_string().contains("target"), "{error}"); } #[test] fn a_carrier_off_the_deck_names_the_rail_span() { - let mut profile = StarTargetProfile::default(); + let mut profile = StarAdapterProfile::default(); profile.deck.carriers.insert( "tips".into(), CarrierPlacement { @@ -858,7 +815,7 @@ mod tests { #[test] fn overlapping_carriers_are_rejected() { - let mut profile = StarTargetProfile::default(); + let mut profile = StarAdapterProfile::default(); profile.deck.carriers.insert( "rogue".into(), CarrierPlacement { @@ -877,7 +834,7 @@ mod tests { #[test] fn two_resources_cannot_share_a_site() { - let mut profile = StarTargetProfile::default(); + let mut profile = StarAdapterProfile::default(); profile.stages.transformation.dna_plate.slots = vec!["plates_a/1".into()]; let error = profile .validate() @@ -890,7 +847,7 @@ mod tests { #[test] fn a_vessel_where_tips_belong_is_rejected() { - let mut profile = StarTargetProfile::default(); + let mut profile = StarAdapterProfile::default(); profile.stages.assembly.small_tips.labware = "pcr_plate_96".into(); let error = profile .validate() @@ -907,7 +864,7 @@ mod tests { #[test] fn labware_capacity_must_match_the_catalog() { - let mut profile = StarTargetProfile::default(); + let mut profile = StarAdapterProfile::default(); profile.deck.source_rack.capacity = 96; let error = profile .validate() @@ -926,7 +883,7 @@ mod tests { #[test] fn site_addresses_resolve_to_catalog_geometry() { - let profile = StarTargetProfile::default(); + let profile = StarAdapterProfile::default(); let site = profile .resolve_labware("test", "plates_a/1", "pcr_plate_96") .expect("the reaction plate site resolves"); diff --git a/crates/lab-compiler/src/backend/markdown.rs b/crates/lab-compiler/src/backend/markdown.rs index 54dc47b..f28e2c7 100644 --- a/crates/lab-compiler/src/backend/markdown.rs +++ b/crates/lab-compiler/src/backend/markdown.rs @@ -38,12 +38,6 @@ fn render_blocks(blocks: &[Block], offset: u8) -> String { } output.push('\n'); } - Block::Numbered(items) => { - for (index, item) in items.iter().enumerate() { - writeln!(output, "{}. {}", index + 1, inlines(item)).unwrap(); - } - output.push('\n'); - } Block::Table { columns, rows } => { let headers = columns .iter() @@ -97,18 +91,17 @@ mod tests { let mut doc = Doc::new(DocMeta { title: "Manual protocol".into(), subtitle: "Operator manual".into(), - target: "bench-1".into(), + adapter_profile: "adapter-1".into(), instrument: "Test rig".into(), }); doc.notice([ text("Generated concept protocol for "), - code("bench-1"), + code("adapter-1"), text("."), ]); doc.heading(1, [text("Stage 1")]); doc.para([text("Keep everything at 4 °C.")]); doc.bullets([vec![text("Volume: 30 µL")]]); - doc.numbered([vec![text("first")], vec![text("second")]]); doc.table( [Column::left("Reagent"), Column::right("Volume")], [ @@ -119,11 +112,10 @@ mod tests { let rendered = render(&doc); assert!(rendered.starts_with("# Manual protocol\n")); - assert!(rendered.contains("> Generated concept protocol for `bench-1`.")); + assert!(rendered.contains("> Generated concept protocol for `adapter-1`.")); assert!(rendered.contains("## Stage 1")); assert!(rendered.contains("Keep everything at 4 °C.")); assert!(rendered.contains("- Volume: 30 µL")); - assert!(rendered.contains("1. first\n2. second")); assert!(rendered.contains("| Reagent | Volume |")); assert!(rendered.contains("| --- | ---: |")); assert!(rendered.contains("| **Total** | **30 µL** |")); diff --git a/crates/lab-compiler/src/backend/mod.rs b/crates/lab-compiler/src/backend/mod.rs index e28604d..fd97291 100644 --- a/crates/lab-compiler/src/backend/mod.rs +++ b/crates/lab-compiler/src/backend/mod.rs @@ -1,24 +1,11 @@ -//! Compiler backend contracts, the planning every backend shares, and the -//! concrete execution targets. +//! Compiler backend contracts, shared device planning, and concrete adapter implementations. //! -//! Three layers live here. The contracts — [`Backend`], [`BackendEmitter`], -//! [`BackendDescriptor`], [`TargetConstraintError`] — say what a backend is. -//! The modules beside them are planning that holds for any liquid handler and -//! names no robot: provenance analysis over Protocol LAIR, projection into the -//! target-independent build graph, SBS plate geometry and well allocation, the -//! labware groupings every bench profile declares, and the rendering of a -//! dependency-driven build. Backend identity enters that planning only as a -//! parameter, so a capacity error names the machine that planned the build. +//! Three layers live here. The contracts, including [`Backend`], [`BackendEmitter`], [`AdapterDescriptor`], and [`AdapterConstraintError`], define implementation behavior. The modules beside them hold planning shared by liquid-handler adapters: provenance analysis over Protocol LAIR, projection into the portable build graph, SBS plate geometry and well allocation, checked resource groupings, and dependency-build rendering. Adapter identity enters that planning only as a parameter, so a capacity error names the implementation that planned the device run. //! -//! Below both sits one module per vendor family — [`opentrons`] and -//! [`hamilton`] — holding one module per machine. A target implementation -//! owns the selection from -//! verified LAIR into a target IR, target validation and resource planning, -//! and concrete emitters. The language frontend and generic output renderers -//! deliberately do not depend on any target module. +//! Below both sits one module per vendor family, [`opentrons`] and [`hamilton`], holding one module per adapter. An adapter owns selection from verified LAIR into a device IR, implementation validation and resource planning, and concrete emitters. The language frontend and generic output renderers deliberately do not depend on a device implementation. +mod adapters; mod constraints; -mod descriptor; mod document; mod error; mod graph; @@ -28,18 +15,15 @@ pub mod opentrons; mod package; mod profile; mod resources; -mod target_profiles; mod trace; mod traits; mod typst; -pub mod workcell; -pub use constraints::TargetConstraintError; -pub use descriptor::{BackendDescriptor, BackendTarget}; -pub use target_profiles::{ - CAPABILITIES_FORMAT, KNOWN_BACKENDS, PROFILE_SCHEMA_VERSION, StationCapability, - TargetCapabilitiesDocument, TargetCapability, TargetKind, TargetProfile, - TargetProfileContractError, VALIDATION_FORMAT, ValidatedTargetProfile, default_target_profile, - parse_target_profile, target_capabilities, validate_target_profile, +pub use adapters::{ + ADAPTER_CATALOG_FORMAT, ADAPTER_PROFILE_SCHEMA_VERSION, AdapterCatalog, AdapterDescriptor, + AdapterLoweringError, AdapterProfileContractError, AdapterServices, ValidatedAdapterProfile, + adapter_catalog, default_adapter_profile, lower_dependency_build_with_adapter, + validate_adapter_profile, }; +pub use constraints::AdapterConstraintError; pub use traits::{Backend, BackendEmitter}; diff --git a/crates/lab-compiler/src/backend/opentrons/flex/backend.rs b/crates/lab-compiler/src/backend/opentrons/flex/backend.rs index a8243a6..552627f 100644 --- a/crates/lab-compiler/src/backend/opentrons/flex/backend.rs +++ b/crates/lab-compiler/src/backend/opentrons/flex/backend.rs @@ -1,30 +1,28 @@ //! Flex implementation of the compiler backend contracts. -use std::collections::BTreeSet; - use thiserror::Error; -use crate::backend::{Backend, BackendDescriptor, BackendEmitter, BackendTarget}; +use crate::backend::{Backend, BackendEmitter}; use crate::{ArtifactBundle, ProtocolLairProgram}; use crate::backend::opentrons::flex::plan::{ FlexEmissionError, FlexExecutionPlan, FlexPlanningError, emit_program, plan_build, }; -use crate::backend::opentrons::flex::profile::FlexTargetProfile; +use crate::backend::opentrons::flex::profile::FlexAdapterProfile; /// The Flex backend bound to one bench. Planning reads every deck, labware, /// and instrument decision from the profile it carries. #[derive(Clone, Debug, Default)] pub struct FlexBackend { - profile: FlexTargetProfile, + profile: FlexAdapterProfile, } impl FlexBackend { - pub fn new(profile: FlexTargetProfile) -> Self { + pub fn new(profile: FlexAdapterProfile) -> Self { Self { profile } } - pub fn profile(&self) -> &FlexTargetProfile { + pub fn profile(&self) -> &FlexAdapterProfile { &self.profile } } @@ -39,25 +37,6 @@ impl Backend for FlexBackend { type Program = FlexExecutionPlan; type Error = FlexCompileError; - fn descriptor(&self) -> BackendDescriptor { - BackendDescriptor { - id: "opentrons".into(), - display_name: "Opentrons JSON protocol".into(), - manufacturer: Some("Opentrons".into()), - targets: vec![BackendTarget { - id: "flex".into(), - display_name: "Opentrons Flex".into(), - capabilities: BTreeSet::from([ - "liquid_transfer".into(), - "temperature_control".into(), - "thermocycler".into(), - "gripper".into(), - "json_protocol".into(), - ]), - }], - } - } - fn compile(&self, protocol: &ProtocolLairProgram) -> Result { Ok(plan_build(protocol, &self.profile)?) } @@ -83,8 +62,6 @@ mod tests { let protocol = golden_gate_protocol(); let backend = FlexBackend::default(); let program = backend.compile(&protocol).unwrap(); - assert_eq!(backend.descriptor().id, "opentrons"); - assert_eq!(backend.descriptor().targets[0].id, "flex"); assert_eq!(program.assemblies.len(), 2); // One plasmid feeds two chassis, so four strains come from two // assemblies rather than one strain per assembly. diff --git a/crates/lab-compiler/src/backend/opentrons/flex/emit/manual.rs b/crates/lab-compiler/src/backend/opentrons/flex/emit/manual.rs index 6df2b1b..5d09a61 100644 --- a/crates/lab-compiler/src/backend/opentrons/flex/emit/manual.rs +++ b/crates/lab-compiler/src/backend/opentrons/flex/emit/manual.rs @@ -7,7 +7,7 @@ use crate::backend::document::{Block, Column, Doc, DocMeta, bold, code, text}; use crate::backend::opentrons::flex::plan::{FlexExecutionPlan, FlexWell}; -use crate::backend::opentrons::flex::profile::FlexTargetProfile; +use crate::backend::opentrons::flex::profile::FlexAdapterProfile; /// Wells are addressed as plate and well, because a stage may hold several /// identical plates. The first plate is unnumbered so the common @@ -32,7 +32,7 @@ fn fragment() -> Doc { /// The machine, its modules, and the deck: everything that holds for any /// run compiled against this bench profile. -pub(in crate::backend) fn bench_blocks(deck: &FlexTargetProfile) -> Vec { +pub(in crate::backend) fn bench_blocks(deck: &FlexAdapterProfile) -> Vec { let mut doc = fragment(); doc.heading(1, [text("How Lab and the robot divide the work")]); @@ -447,7 +447,7 @@ pub(in crate::backend) fn boundary_blocks() -> Vec { let mut doc = fragment(); doc.heading(1, [text("Execution boundary")]); doc.para_text( - "This concept spike allocates one 96-well reaction plate, one DNA plate, one dilution plate, one agar plate, and 24-well source racks. It does not resolve inventory lots, verify DNA concentrations, design overhangs, domesticate internal restriction sites, or qualify the protocol for a specific lab.", + "This concept spike allocates one 96-well reaction plate, one DNA plate, one dilution plate, one agar plate, and 24-well source racks. Dependency planning may freeze exact inventory lots, but this device plan does not reason over their quantities, verify DNA concentrations, design overhangs, domesticate internal restriction sites, or qualify the protocol for a specific lab.", ); doc.blocks } @@ -456,12 +456,12 @@ pub(in crate::backend) fn render_manual_protocol(manifest: &FlexExecutionPlan) - let mut doc = Doc::new(DocMeta::new( "Automated plasmid build", "Operator manual for one robot session", - &manifest.target, + &manifest.adapter, "Opentrons Flex", )); doc.notice([ text("Concept protocol generated for "), - code(&manifest.target), + code(&manifest.adapter), text(". Review and qualify it for the actual laboratory before execution."), ]); doc.blocks.extend(bench_blocks(&manifest.deck)); diff --git a/crates/lab-compiler/src/backend/opentrons/flex/emit/protocols.rs b/crates/lab-compiler/src/backend/opentrons/flex/emit/protocols.rs index 82944e1..5c4fc14 100644 --- a/crates/lab-compiler/src/backend/opentrons/flex/emit/protocols.rs +++ b/crates/lab-compiler/src/backend/opentrons/flex/emit/protocols.rs @@ -16,7 +16,7 @@ use opentrons_protocol::{ }; use crate::backend::opentrons::flex::plan::{FlexEmissionError, FlexExecutionPlan}; -use crate::backend::opentrons::flex::profile::{FlexTargetProfile, TipRacks}; +use crate::backend::opentrons::flex::profile::{FlexAdapterProfile, TipRacks}; use crate::backend::resources::plate_wells; pub(in crate::backend::opentrons::flex) fn render_assembly_protocol( @@ -369,7 +369,7 @@ struct Instrument { max_volume: f64, } -fn stage_builder(profile: &FlexTargetProfile, protocol_name: &str) -> FlexProtocolBuilder { +fn stage_builder(profile: &FlexAdapterProfile, protocol_name: &str) -> FlexProtocolBuilder { FlexProtocolBuilder::with_trash( Metadata { protocol_name: Some(protocol_name.into()), diff --git a/crates/lab-compiler/src/backend/opentrons/flex/mod.rs b/crates/lab-compiler/src/backend/opentrons/flex/mod.rs index 4de8d8f..9960a31 100644 --- a/crates/lab-compiler/src/backend/opentrons/flex/mod.rs +++ b/crates/lab-compiler/src/backend/opentrons/flex/mod.rs @@ -11,9 +11,7 @@ mod package; mod plan; mod profile; -/// This backend's identity. A target profile declares it, planning stamps it -/// into every execution plan and target-constraint error, and no other -/// spelling of it exists. +/// Stable adapter identity used by explicit Asset bindings, device plans, and adapter diagnostics. pub(in crate::backend::opentrons::flex) const BACKEND: &str = "opentrons.flex"; pub use crate::backend::opentrons::flex::backend::{FlexBackend, FlexCompileError}; @@ -25,4 +23,4 @@ pub use crate::backend::opentrons::flex::plan::{ FlexExecutionPlan, FlexPlanningError, FlexPlatingPlan, FlexStrainChemistry, FlexStrainPlan, FlexTransformationPlan, compile_build, emit_program, plan_build, }; -pub use crate::backend::opentrons::flex::profile::{FlexProfileError, FlexTargetProfile}; +pub use crate::backend::opentrons::flex::profile::{FlexAdapterProfile, FlexProfileError}; diff --git a/crates/lab-compiler/src/backend/opentrons/flex/package/compile.rs b/crates/lab-compiler/src/backend/opentrons/flex/package/compile.rs index 482c4ba..9d89c86 100644 --- a/crates/lab-compiler/src/backend/opentrons/flex/package/compile.rs +++ b/crates/lab-compiler/src/backend/opentrons/flex/package/compile.rs @@ -6,7 +6,7 @@ use thiserror::Error; use crate::{ArtifactBundle, ArtifactError, ProtocolLairProgram}; -use crate::backend::opentrons::flex::profile::FlexTargetProfile; +use crate::backend::opentrons::flex::profile::FlexAdapterProfile; use crate::planning::{BuildInventory, DependencyBuildManifest}; use crate::planning::{DependencyGraphError, resolve_dependency_graph}; @@ -56,12 +56,12 @@ pub enum FlexDependencyBuildError { } /// Specialize a source-derived dependency graph into independently executable -/// Flex packages. Graph resolution itself is target-neutral; only the +/// Flex packages. Graph resolution itself is facility-independent; only the /// requirements projected into each graph node and the emitted batches are /// owned by this module. pub fn compile_dependency_build( protocol: &ProtocolLairProgram, - profile: &FlexTargetProfile, + profile: &FlexAdapterProfile, inventory: &BuildInventory, ) -> Result { let graph = @@ -97,7 +97,7 @@ pub fn compile_dependency_build( DocMeta::new( "Dependency report", "Artifact graph, wave schedule, and blockers", - &profile.target.name, + &profile.name, "Opentrons Flex", ), &manifest, @@ -145,7 +145,7 @@ pub fn compile_dependency_build( DocMeta::new( "Automated plasmid build", "Operator instructions for the full dependency-driven build", - &profile.target.name, + &profile.name, "Opentrons Flex", ), &manifest, diff --git a/crates/lab-compiler/src/backend/opentrons/flex/plan/build.rs b/crates/lab-compiler/src/backend/opentrons/flex/plan/build.rs index 575a9d2..aadd269 100644 --- a/crates/lab-compiler/src/backend/opentrons/flex/plan/build.rs +++ b/crates/lab-compiler/src/backend/opentrons/flex/plan/build.rs @@ -5,7 +5,7 @@ use std::collections::{BTreeMap, BTreeSet}; use pliron::context::Context; use crate::ProtocolLairProgram; -use crate::backend::opentrons::flex::profile::{FlexTargetProfile, Plates, Stages}; +use crate::backend::opentrons::flex::profile::{FlexAdapterProfile, Plates, Stages}; use crate::backend::opentrons::flex::BACKEND; use crate::backend::opentrons::flex::plan::constraints::{ @@ -29,14 +29,14 @@ use crate::backend::trace::{AssemblyTrace, ProtocolTraces, StrainTrace, analyze_ /// Markdown emitters. pub fn plan_build( protocol: &ProtocolLairProgram, - profile: &FlexTargetProfile, + profile: &FlexAdapterProfile, ) -> Result { plan_selected_build(protocol, profile, None) } pub(in crate::backend::opentrons::flex) fn plan_selected_build( protocol: &ProtocolLairProgram, - profile: &FlexTargetProfile, + profile: &FlexAdapterProfile, selected_artifacts: Option<&BTreeSet>, ) -> Result { let context = protocol.context(); @@ -74,8 +74,8 @@ pub(in crate::backend::opentrons::flex) fn plan_selected_build( assign_all_source_wells(&traces, context, rack_capacity)?; Ok(FlexExecutionPlan { - schema_version: "lab.automation.v0".into(), - target: BACKEND.into(), + schema_version: "lab.automation.v1".into(), + adapter: BACKEND.into(), deck: profile.clone(), assembly_source_wells, transformation_source_wells, @@ -97,10 +97,8 @@ fn validate_traces(traces: &ProtocolTraces, context: &Context) -> Result<(), Fle validate_uniform_batch_settings(&traces.strains, context) } -/// Rejects a target profile that declares labware in a well count this -/// backend has no row/column layout for. The tip racks are included because -/// the JSON emitter addresses tip wells by name. -fn require_deck_geometry(profile: &FlexTargetProfile) -> Result<(), FlexPlanningError> { +/// Rejects adapter configuration that declares labware in a well count this implementation has no row/column layout for. The tip racks are included because the JSON emitter addresses tip wells by name. +fn require_deck_geometry(profile: &FlexAdapterProfile) -> Result<(), FlexPlanningError> { let deck = &profile.deck; let stages = &profile.stages; require_known_geometry("the source rack", deck.temperature_module.capacity)?; @@ -397,15 +395,15 @@ fn assign_all_source_wells( #[cfg(test)] mod tests { use crate::backend::opentrons::flex::plan::build::*; - use crate::backend::opentrons::flex::profile::FlexTargetProfile; + use crate::backend::opentrons::flex::profile::FlexAdapterProfile; use crate::test_support::golden_gate_protocol; #[test] fn allocates_both_stages_against_the_reference_bench() { let protocol = golden_gate_protocol(); - let plan = plan_build(&protocol, &FlexTargetProfile::default()).unwrap(); - assert_eq!(plan.target, "opentrons.flex"); - assert_eq!(plan.schema_version, "lab.automation.v0"); + let plan = plan_build(&protocol, &FlexAdapterProfile::default()).unwrap(); + assert_eq!(plan.adapter, "opentrons.flex"); + assert_eq!(plan.schema_version, "lab.automation.v1"); assert_eq!(plan.assemblies.len(), 2); assert_eq!(plan.strains.len(), 4); assert_eq!(plan.assemblies[0].assembly_wells, ["A1"]); @@ -419,7 +417,7 @@ mod tests { #[test] fn a_narrow_agar_plate_is_a_capacity_error_naming_this_backend() { let protocol = golden_gate_protocol(); - let mut narrow = FlexTargetProfile::default(); + let mut narrow = FlexAdapterProfile::default(); narrow.stages.plating.agar_plate.slots = vec!["B2".to_owned()]; narrow.stages.plating.agar_plate.capacity = 15; let error = plan_build(&protocol, &narrow) diff --git a/crates/lab-compiler/src/backend/opentrons/flex/plan/bundle.rs b/crates/lab-compiler/src/backend/opentrons/flex/plan/bundle.rs index e5c74ca..ae4ff5f 100644 --- a/crates/lab-compiler/src/backend/opentrons/flex/plan/bundle.rs +++ b/crates/lab-compiler/src/backend/opentrons/flex/plan/bundle.rs @@ -9,7 +9,7 @@ use crate::backend::opentrons::flex::emit::{ }; use crate::backend::{markdown, typst}; -use crate::backend::opentrons::flex::profile::FlexTargetProfile; +use crate::backend::opentrons::flex::profile::FlexAdapterProfile; use crate::backend::opentrons::flex::plan::{ FlexBuildError, FlexEmissionError, FlexExecutionPlan, plan_build, @@ -130,7 +130,7 @@ impl FlexBundle { pub fn compile_build( protocol: &ProtocolLairProgram, - profile: &FlexTargetProfile, + profile: &FlexAdapterProfile, ) -> Result { Ok(FlexBundle::from_plan(plan_build(protocol, profile)?)?) } diff --git a/crates/lab-compiler/src/backend/opentrons/flex/plan/constraints.rs b/crates/lab-compiler/src/backend/opentrons/flex/plan/constraints.rs index b2371ce..e55ad25 100644 --- a/crates/lab-compiler/src/backend/opentrons/flex/plan/constraints.rs +++ b/crates/lab-compiler/src/backend/opentrons/flex/plan/constraints.rs @@ -1,6 +1,6 @@ //! Flex-specific parameter and batch-capacity validation. -use crate::backend::TargetConstraintError; +use crate::backend::AdapterConstraintError; use crate::backend::trace::{AssemblyTrace, StrainTrace}; use crate::backend::opentrons::flex::BACKEND; @@ -24,8 +24,8 @@ pub(super) fn validate_assembly_constraints( + trace.chemistry(context, "enzyme_volume_ul") + trace.chemistry(context, "part_volume_ul") * dna_pieces; if required_ul > reaction_volume_ul { - return Err(TargetConstraintError::CapacityExceeded { - target: BACKEND.into(), + return Err(AdapterConstraintError::CapacityExceeded { + adapter: BACKEND.into(), operation: "assembly".into(), subject: artifact, resource: "reaction_volume".into(), @@ -64,8 +64,8 @@ fn require_range( maximum: u8, ) -> Result<(), FlexPlanningError> { if !(1..=maximum).contains(&value) { - return Err(TargetConstraintError::ParameterOutOfRange { - target: BACKEND.into(), + return Err(AdapterConstraintError::ParameterOutOfRange { + adapter: BACKEND.into(), subject: artifact.to_owned(), parameter: parameter.into(), minimum: 1, @@ -96,8 +96,8 @@ pub(super) fn validate_uniform_batch_settings( trace.serial_dilutions(context), ) != expected }) { - Err(TargetConstraintError::NonUniformParameters { - target: BACKEND.into(), + Err(AdapterConstraintError::NonUniformParameters { + adapter: BACKEND.into(), subject: "automation_batch".into(), parameters: vec![ "transformation_replicates".into(), @@ -117,8 +117,8 @@ pub(super) fn plate_capacity_error( required: usize, capacity: usize, ) -> FlexPlanningError { - TargetConstraintError::CapacityExceeded { - target: BACKEND.into(), + AdapterConstraintError::CapacityExceeded { + adapter: BACKEND.into(), operation: stage.into(), subject: "automation_batch".into(), resource: resource.into(), @@ -136,8 +136,8 @@ pub(super) fn require_tip_capacity( capacity: usize, ) -> Result<(), FlexPlanningError> { if required > capacity { - Err(TargetConstraintError::CapacityExceeded { - target: BACKEND.into(), + Err(AdapterConstraintError::CapacityExceeded { + adapter: BACKEND.into(), operation: stage.into(), subject: "automation_batch".into(), resource: format!("{pipette}_tip_rack"), diff --git a/crates/lab-compiler/src/backend/opentrons/flex/plan/error.rs b/crates/lab-compiler/src/backend/opentrons/flex/plan/error.rs index 9a43071..d2615ba 100644 --- a/crates/lab-compiler/src/backend/opentrons/flex/plan/error.rs +++ b/crates/lab-compiler/src/backend/opentrons/flex/plan/error.rs @@ -1,19 +1,19 @@ use thiserror::Error; use crate::ArtifactError; -use crate::backend::TargetConstraintError; +use crate::backend::AdapterConstraintError; use crate::backend::error::PlanningError; #[derive(Debug, Error, PartialEq, Eq)] pub enum FlexPlanningError { #[error(transparent)] - Constraint(Box), - #[error("invalid target-selected Protocol LAIR: {0}")] + Constraint(Box), + #[error("invalid method-selected Protocol LAIR: {0}")] InvalidProtocol(String), } -impl From for FlexPlanningError { - fn from(error: TargetConstraintError) -> Self { +impl From for FlexPlanningError { + fn from(error: AdapterConstraintError) -> Self { Self::Constraint(Box::new(error)) } } diff --git a/crates/lab-compiler/src/backend/opentrons/flex/plan/execution.rs b/crates/lab-compiler/src/backend/opentrons/flex/plan/execution.rs index 23e2b0f..6e204d8 100644 --- a/crates/lab-compiler/src/backend/opentrons/flex/plan/execution.rs +++ b/crates/lab-compiler/src/backend/opentrons/flex/plan/execution.rs @@ -4,7 +4,7 @@ use std::collections::BTreeMap; use serde::Serialize; -use crate::backend::opentrons::flex::profile::FlexTargetProfile; +use crate::backend::opentrons::flex::profile::FlexAdapterProfile; pub use crate::backend::resources::Well as FlexWell; @@ -15,12 +15,10 @@ pub use crate::backend::resources::Well as FlexWell; #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct FlexExecutionPlan { pub schema_version: String, - /// The backend that produced this plan, spelled exactly as a target - /// profile declares it. - pub target: String, - /// The bench this plan was allocated against. Emission reads every labware - /// name, deck slot, and mount from here rather than from a constant. - pub deck: FlexTargetProfile, + /// The explicit adapter implementation that produced this device plan. + pub adapter: String, + /// Checked implementation configuration for the allocated Asset binding. + pub deck: FlexAdapterProfile, pub assembly_source_wells: BTreeMap, pub transformation_source_wells: BTreeMap, /// DNA-plate well holding each plasmid a strain is transformed from. A diff --git a/crates/lab-compiler/src/backend/opentrons/flex/profile/defaults.rs b/crates/lab-compiler/src/backend/opentrons/flex/profile/defaults.rs index 7f6cf2a..1d50074 100644 --- a/crates/lab-compiler/src/backend/opentrons/flex/profile/defaults.rs +++ b/crates/lab-compiler/src/backend/opentrons/flex/profile/defaults.rs @@ -1,21 +1,10 @@ //! Reference-bench default values for every Flex profile field. -use crate::backend::opentrons::flex::BACKEND; use crate::backend::opentrons::flex::profile::schema::{ AssemblyStage, MediaRack, Pipette, Plates, PlatingStage, TemperatureModule, Thermocycler, TipRacks, TransformationStage, Trash, }; -/// Bench name for a build that named no profile: the reference bench this -/// backend was developed against. -pub(super) fn default_bench_name() -> String { - "reference-bench".to_owned() -} - -pub(super) fn default_backend() -> String { - BACKEND.to_owned() -} - pub(super) fn default_small_pipette() -> Pipette { Pipette { model: "p50_single_flex".to_owned(), diff --git a/crates/lab-compiler/src/backend/opentrons/flex/profile/error.rs b/crates/lab-compiler/src/backend/opentrons/flex/profile/error.rs index d077d27..db74bd5 100644 --- a/crates/lab-compiler/src/backend/opentrons/flex/profile/error.rs +++ b/crates/lab-compiler/src/backend/opentrons/flex/profile/error.rs @@ -1,16 +1,11 @@ -//! Errors from parsing and validating a Flex target profile. +//! Errors from parsing and validating Flex adapter configuration. use thiserror::Error; #[derive(Debug, Error)] pub enum FlexProfileError { - #[error("failed to parse Flex target profile: {0}")] + #[error("failed to parse Flex adapter profile: {0}")] Parse(#[from] toml::de::Error), - #[error("target profile declares backend '{found}', but this backend is '{expected}'")] - WrongBackend { - expected: &'static str, - found: String, - }, #[error( "the {instrument} instrument names pipette '{model}', which is not a Flex pipette; Flex pipettes are p50_single_flex, p50_multi_flex, p1000_single_flex, p1000_multi_flex, and p1000_96" )] diff --git a/crates/lab-compiler/src/backend/opentrons/flex/profile/mod.rs b/crates/lab-compiler/src/backend/opentrons/flex/profile/mod.rs index 5a8da03..7c0db51 100644 --- a/crates/lab-compiler/src/backend/opentrons/flex/profile/mod.rs +++ b/crates/lab-compiler/src/backend/opentrons/flex/profile/mod.rs @@ -1,14 +1,8 @@ -//! Site configuration for one Opentrons Flex bench. +//! Operational configuration for the Opentrons Flex adapter. //! -//! A profile describes the laboratory, not the science: which modules are -//! installed, which labware sits in which deck slot, where the trash bin is, -//! and which pipette is on which mount. Two laboratories running the same Lab -//! program supply different profiles; neither program changes. +//! Facility allocation has already selected an exact Asset before this profile is read. The profile contains only checked configuration the implementation still needs to produce an executable protocol. It cannot select a facility Asset or another adapter. //! -//! Every field has a default, so a profile states only what differs from the -//! bench this backend was developed against. Unknown keys are rejected, -//! because a misspelled slot silently falling back to a default is how a -//! protocol ends up aspirating from the wrong place. +//! Every field has a default, so a profile states only what differs from the reference implementation configuration. Unknown keys are rejected, because a misspelled slot silently falling back to a default is how a protocol ends up aspirating from the wrong place. mod defaults; mod error; @@ -20,22 +14,23 @@ use opentrons_protocol::{FlexPipetteName, FlexSlot, TrashArea}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::backend::opentrons::flex::BACKEND; pub use crate::backend::opentrons::flex::profile::error::FlexProfileError; // Only the field types other `flex` submodules reach into directly -// are re-exported; the rest of the schema stays behind `FlexTargetProfile`. -use crate::backend::opentrons::flex::profile::schema::{FlexDeck, Instruments, TargetMetadata}; +// are re-exported; the rest of the schema stays behind `FlexAdapterProfile`. +use crate::backend::opentrons::flex::profile::schema::{FlexDeck, Instruments}; pub use crate::backend::opentrons::flex::profile::schema::{Pipette, Plates, Stages, TipRacks}; /// Slots the installed thermocycler occupies. const THERMOCYCLER_SLOTS: [FlexSlot; 2] = [FlexSlot::A1, FlexSlot::B1]; -/// The complete Flex site configuration consumed by planning and emission. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +/// The complete Flex implementation configuration consumed by planning and emission. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] -pub struct FlexTargetProfile { - #[serde(default)] - pub target: TargetMetadata, +pub struct FlexAdapterProfile { + /// File-stem label supplied by the exact Asset binding. It is review metadata, not profile input. + #[serde(skip)] + #[schemars(skip)] + pub name: String, #[serde(default)] pub instruments: Instruments, #[serde(default)] @@ -44,24 +39,27 @@ pub struct FlexTargetProfile { pub stages: Stages, } -impl FlexTargetProfile { - /// Load the profile named `name`. The name is the profile's filename under - /// `targets/`, supplied by whoever resolved it, so the file itself never - /// states which bench it is. +impl Default for FlexAdapterProfile { + fn default() -> Self { + Self { + name: "opentrons.flex".to_owned(), + instruments: Instruments::default(), + deck: FlexDeck::default(), + stages: Stages::default(), + } + } +} + +impl FlexAdapterProfile { + /// Load operational configuration for one exact Asset binding. pub fn parse(name: &str, text: &str) -> Result { let mut profile: Self = toml::from_str(text)?; - profile.target.name = name.to_owned(); + profile.name = name.to_owned(); profile.validate()?; Ok(profile) } pub fn validate(&self) -> Result<(), FlexProfileError> { - if self.target.backend != BACKEND { - return Err(FlexProfileError::WrongBackend { - expected: BACKEND, - found: self.target.backend.clone(), - }); - } self.validate_instruments()?; self.validate_deck()?; for (stage, claims) in [ @@ -263,8 +261,8 @@ mod tests { #[test] fn an_empty_profile_describes_the_reference_bench() { - let profile = FlexTargetProfile::parse("reference-bench", "").unwrap(); - assert_eq!(profile, FlexTargetProfile::default()); + let profile = FlexAdapterProfile::parse("reference-bench", "").unwrap(); + assert_eq!(profile.name, "reference-bench"); assert_eq!(profile.deck.temperature_module.slot, "C1"); assert_eq!(profile.deck.trash.area, "movableTrashA3"); assert_eq!(profile.stages.plating.agar_plate.slots, ["B2", "B3"]); @@ -273,7 +271,7 @@ mod tests { #[test] fn a_profile_overrides_only_what_it_states() { - let profile = FlexTargetProfile::parse( + let profile = FlexAdapterProfile::parse( "bench-two", r#" [stages.plating.agar_plate] @@ -292,16 +290,16 @@ capacity = 96 } #[test] - fn rejects_a_profile_written_for_another_backend() { + fn rejects_an_embedded_target_or_adapter_selector() { let error = - FlexTargetProfile::parse("bench-two", "[target]\nbackend = \"opentrons.ot2\"\n") - .expect_err("this backend compiles only its own profiles"); - assert!(error.to_string().contains(BACKEND), "{error}"); + FlexAdapterProfile::parse("flex-runtime", "[target]\nbackend = \"opentrons.ot2\"\n") + .expect_err("only the exact Asset binding may select an adapter"); + assert!(error.to_string().contains("target"), "{error}"); } #[test] fn rejects_an_ot2_pipette_on_a_flex_bench() { - let error = FlexTargetProfile::parse( + let error = FlexAdapterProfile::parse( "bench-two", "[instruments.small]\nmodel = \"p20_single_gen2\"\nmount = \"left\"\n", ) @@ -311,7 +309,7 @@ capacity = 96 #[test] fn rejects_labware_placed_under_the_thermocycler() { - let error = FlexTargetProfile::parse( + let error = FlexAdapterProfile::parse( "bench-two", r#" [stages.assembly.small_tips] @@ -326,7 +324,7 @@ capacity = 96 #[test] fn rejects_labware_placed_in_the_trash_slot() { - let error = FlexTargetProfile::parse( + let error = FlexAdapterProfile::parse( "bench-two", r#" [stages.assembly.small_tips] @@ -341,7 +339,7 @@ capacity = 96 #[test] fn rejects_a_temperature_module_in_column_2() { - let error = FlexTargetProfile::parse( + let error = FlexAdapterProfile::parse( "bench-two", "[deck.temperature_module]\nmodel = \"temperatureModuleV2\"\nslot = \"C2\"\nlabware = \"opentrons_24_aluminumblock_nest_1.5ml_snapcap\"\ncapacity = 24\n", ) @@ -351,7 +349,7 @@ capacity = 96 #[test] fn rejects_a_staging_slot() { - let error = FlexTargetProfile::parse( + let error = FlexAdapterProfile::parse( "bench-two", r#" [stages.transformation.dna_plate] @@ -366,7 +364,7 @@ capacity = 96 #[test] fn rejects_an_unknown_key_rather_than_silently_ignoring_it() { - let error = FlexTargetProfile::parse("bench-two", "[stages.plating]\nagar_plates = 2\n") + let error = FlexAdapterProfile::parse("bench-two", "[stages.plating]\nagar_plates = 2\n") .expect_err("a misspelled key must not fall back to a default"); assert!(error.to_string().contains("parse"), "{error}"); } diff --git a/crates/lab-compiler/src/backend/opentrons/flex/profile/schema.rs b/crates/lab-compiler/src/backend/opentrons/flex/profile/schema.rs index a5c5c5e..65e010f 100644 --- a/crates/lab-compiler/src/backend/opentrons/flex/profile/schema.rs +++ b/crates/lab-compiler/src/backend/opentrons/flex/profile/schema.rs @@ -1,5 +1,4 @@ -//! Deserializable shape of a Flex target profile: instruments, deck modules, -//! the trash bin, and the labware each build stage claims. +//! Deserializable shape of Flex adapter configuration: instruments, deck modules, the trash bin, and the labware each build stage claims. use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -8,28 +7,6 @@ pub use crate::backend::profile::{MediaRack, Plates, TipRacks}; use crate::backend::opentrons::flex::profile::defaults::*; -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct TargetMetadata { - /// The bench this profile describes, named by whoever loaded it: a profile - /// is selected as `targets/.toml`, so the file does not repeat its - /// own name and cannot disagree with it. Emitted plans carry the name so - /// an operator can see which bench a protocol was compiled for. - #[serde(skip_deserializing, default = "default_bench_name")] - pub name: String, - #[serde(default = "default_backend")] - pub backend: String, -} - -impl Default for TargetMetadata { - fn default() -> Self { - Self { - name: default_bench_name(), - backend: default_backend(), - } - } -} - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Instruments { diff --git a/crates/lab-compiler/src/backend/opentrons/ot2/backend.rs b/crates/lab-compiler/src/backend/opentrons/ot2/backend.rs index 8c26f53..b7f7f61 100644 --- a/crates/lab-compiler/src/backend/opentrons/ot2/backend.rs +++ b/crates/lab-compiler/src/backend/opentrons/ot2/backend.rs @@ -1,30 +1,28 @@ //! OT-2 implementation of the compiler backend contracts. -use std::collections::BTreeSet; - use thiserror::Error; -use crate::backend::{Backend, BackendDescriptor, BackendEmitter, BackendTarget}; +use crate::backend::{Backend, BackendEmitter}; use crate::{ArtifactBundle, ProtocolLairProgram}; use crate::backend::opentrons::ot2::plan::{ Ot2EmissionError, Ot2ExecutionPlan, Ot2PlanningError, emit_program, plan_build, }; -use crate::backend::opentrons::ot2::profile::Ot2TargetProfile; +use crate::backend::opentrons::ot2::profile::Ot2AdapterProfile; /// The OT-2 backend bound to one bench. Planning reads every deck, labware, and /// instrument decision from the profile it carries. #[derive(Clone, Debug, Default)] pub struct Ot2Backend { - profile: Ot2TargetProfile, + profile: Ot2AdapterProfile, } impl Ot2Backend { - pub fn new(profile: Ot2TargetProfile) -> Self { + pub fn new(profile: Ot2AdapterProfile) -> Self { Self { profile } } - pub fn profile(&self) -> &Ot2TargetProfile { + pub fn profile(&self) -> &Ot2AdapterProfile { &self.profile } } @@ -39,23 +37,6 @@ impl Backend for Ot2Backend { type Program = Ot2ExecutionPlan; type Error = Ot2CompileError; - fn descriptor(&self) -> BackendDescriptor { - BackendDescriptor { - id: "opentrons".into(), - display_name: "Opentrons Python Protocol API".into(), - manufacturer: Some("Opentrons".into()), - targets: vec![BackendTarget { - id: "ot2".into(), - display_name: "Opentrons OT-2".into(), - capabilities: BTreeSet::from([ - "liquid_transfer".into(), - "temperature_control".into(), - "python_protocol_api".into(), - ]), - }], - } - } - fn compile(&self, protocol: &ProtocolLairProgram) -> Result { Ok(plan_build(protocol, &self.profile)?) } @@ -88,7 +69,6 @@ mod tests { assert!(!protocol.ir().contains("workflow.")); let backend = Ot2Backend::default(); let program = backend.compile(&protocol).unwrap(); - assert_eq!(backend.descriptor().id, "opentrons"); assert_eq!(program.assemblies.len(), 2); // One plasmid feeds two chassis, so four strains come from two // assemblies rather than one strain per assembly. diff --git a/crates/lab-compiler/src/backend/opentrons/ot2/emit/manual.rs b/crates/lab-compiler/src/backend/opentrons/ot2/emit/manual.rs index fd11340..02412ff 100644 --- a/crates/lab-compiler/src/backend/opentrons/ot2/emit/manual.rs +++ b/crates/lab-compiler/src/backend/opentrons/ot2/emit/manual.rs @@ -7,7 +7,7 @@ use crate::backend::document::{Block, Column, Doc, DocMeta, bold, code, text}; use crate::backend::opentrons::ot2::plan::{Ot2ExecutionPlan, Ot2Well}; -use crate::backend::opentrons::ot2::profile::Ot2TargetProfile; +use crate::backend::opentrons::ot2::profile::Ot2AdapterProfile; /// Wells are addressed as plate and well, because a stage may hold several /// identical plates. The first plate is unnumbered so the common @@ -32,7 +32,7 @@ fn fragment() -> Doc { /// The machine, its modules, and the deck: everything that holds for any /// run compiled against this bench profile. -pub(in crate::backend) fn bench_blocks(deck: &Ot2TargetProfile) -> Vec { +pub(in crate::backend) fn bench_blocks(deck: &Ot2AdapterProfile) -> Vec { let mut doc = fragment(); doc.heading(1, [text("How Lab and the robot divide the work")]); @@ -452,7 +452,7 @@ pub(in crate::backend) fn boundary_blocks() -> Vec { let mut doc = fragment(); doc.heading(1, [text("Execution boundary")]); doc.para_text( - "This concept spike allocates one 96-well reaction plate, one DNA plate, one dilution plate, one agar plate, and 24-well source racks. It does not resolve inventory lots, verify DNA concentrations, design overhangs, domesticate internal restriction sites, or qualify the protocol for a specific lab.", + "This concept spike allocates one 96-well reaction plate, one DNA plate, one dilution plate, one agar plate, and 24-well source racks. Dependency planning may freeze exact inventory lots, but this device plan does not reason over their quantities, verify DNA concentrations, design overhangs, domesticate internal restriction sites, or qualify the protocol for a specific lab.", ); doc.blocks } @@ -461,12 +461,12 @@ pub(in crate::backend) fn render_manual_protocol(manifest: &Ot2ExecutionPlan) -> let mut doc = Doc::new(DocMeta::new( "Automated plasmid build", "Operator manual for one robot session", - &manifest.target, + &manifest.adapter, "Opentrons OT-2", )); doc.notice([ text("Concept protocol generated for "), - code(&manifest.target), + code(&manifest.adapter), text(". Review and qualify it for the actual laboratory before execution."), ]); doc.blocks.extend(bench_blocks(&manifest.deck)); @@ -548,7 +548,7 @@ workflow build_reporter_host( .unwrap() .select_protocol() .unwrap(); - let plan = plan_build(&protocol, &Ot2TargetProfile::default()).unwrap(); + let plan = plan_build(&protocol, &Ot2AdapterProfile::default()).unwrap(); let manual = markdown::render(&render_manual_protocol(&plan)); assert!( diff --git a/crates/lab-compiler/src/backend/opentrons/ot2/mod.rs b/crates/lab-compiler/src/backend/opentrons/ot2/mod.rs index f444294..528c84a 100644 --- a/crates/lab-compiler/src/backend/opentrons/ot2/mod.rs +++ b/crates/lab-compiler/src/backend/opentrons/ot2/mod.rs @@ -11,9 +11,7 @@ mod package; mod plan; mod profile; -/// This backend's identity. A target profile declares it, planning stamps it -/// into every execution plan and target-constraint error, and no other -/// spelling of it exists. +/// Stable adapter identity used by explicit Asset bindings, device plans, and adapter diagnostics. pub(in crate::backend::opentrons::ot2) const BACKEND: &str = "opentrons.ot2"; pub use crate::backend::opentrons::ot2::backend::{Ot2Backend, Ot2CompileError}; @@ -25,4 +23,4 @@ pub use crate::backend::opentrons::ot2::plan::{ Ot2ExecutionPlan, Ot2PlanningError, Ot2PlatingPlan, Ot2StrainChemistry, Ot2StrainPlan, Ot2TransformationPlan, compile_build, emit_program, plan_build, }; -pub use crate::backend::opentrons::ot2::profile::{Ot2ProfileError, Ot2TargetProfile}; +pub use crate::backend::opentrons::ot2::profile::{Ot2AdapterProfile, Ot2ProfileError}; diff --git a/crates/lab-compiler/src/backend/opentrons/ot2/package/compile.rs b/crates/lab-compiler/src/backend/opentrons/ot2/package/compile.rs index fd23d0c..8f932fa 100644 --- a/crates/lab-compiler/src/backend/opentrons/ot2/package/compile.rs +++ b/crates/lab-compiler/src/backend/opentrons/ot2/package/compile.rs @@ -6,7 +6,7 @@ use thiserror::Error; use crate::{ArtifactBundle, ArtifactError, ProtocolLairProgram}; -use crate::backend::opentrons::ot2::profile::Ot2TargetProfile; +use crate::backend::opentrons::ot2::profile::Ot2AdapterProfile; use crate::planning::{BuildInventory, DependencyBuildManifest}; use crate::planning::{DependencyGraphError, resolve_dependency_graph}; @@ -56,12 +56,12 @@ pub enum DependencyBuildError { } /// Specialize a source-derived dependency graph into independently executable -/// OT-2 packages. Graph resolution itself is target-neutral; only the +/// OT-2 packages. Graph resolution itself is facility-independent; only the /// requirements projected into each graph node and the emitted batches are /// owned by this module. pub fn compile_dependency_build( protocol: &ProtocolLairProgram, - profile: &Ot2TargetProfile, + profile: &Ot2AdapterProfile, inventory: &BuildInventory, ) -> Result { let graph = protocol_build_graph(protocol).map_err(|source| DependencyBuildError::Backend { @@ -96,7 +96,7 @@ pub fn compile_dependency_build( DocMeta::new( "Dependency report", "Artifact graph, wave schedule, and blockers", - &profile.target.name, + &profile.name, "Opentrons OT-2", ), &manifest, @@ -144,7 +144,7 @@ pub fn compile_dependency_build( DocMeta::new( "Automated plasmid build", "Operator instructions for the full dependency-driven build", - &profile.target.name, + &profile.name, "Opentrons OT-2", ), &manifest, @@ -231,8 +231,8 @@ workflow build_final_host( "#; fn inventory() -> BuildInventory { - BuildInventory { - available_materials: [ + BuildInventory::legacy( + [ "terminal_part", "source_part", "receiver", @@ -247,10 +247,50 @@ workflow build_final_host( "recovery_medium", ] .into_iter() - .map(str::to_owned) - .collect(), - available_artifacts: BTreeSet::new(), - } + .map(str::to_owned), + [], + ) + } + + #[test] + fn semantic_inventory_bindings_reach_the_dependency_manifest() { + let checked = compile_module(include_str!( + "../../../../../../lab-cli/tests/fixtures/material-lot-build.lab" + )) + .unwrap(); + let lots = ["source", "backbone", "enzyme", "ligase", "buffer", "water"] + .into_iter() + .map(|name| { + ( + format!("https://example.org/material-lot-test/{name}"), + vec![format!("https://example.org/material-lot-test/{name}_lot")], + ) + }) + .collect(); + let inventory = BuildInventory::from_material_lots( + &[&checked], + "abc123", + "https://example.org/material-lot-test/facility", + &lots, + ) + .unwrap(); + let protocol = crate::PortableLairProgram::lower(&checked) + .unwrap() + .select_protocol() + .unwrap(); + + let bundle = + compile_dependency_build(&protocol, &Ot2AdapterProfile::default(), &inventory).unwrap(); + + assert_eq!(bundle.manifest.schema_version, "lab.dependency-build.v1"); + assert_eq!(bundle.manifest.nodes.len(), 1); + assert_eq!(bundle.manifest.nodes[0].material_lot_bindings.len(), 6); + assert!( + bundle.manifest.nodes[0] + .material_lot_bindings + .iter() + .all(|binding| binding.component != binding.material_lot) + ); } #[test] @@ -261,7 +301,7 @@ workflow build_final_host( .select_protocol() .unwrap(); let bundle = - compile_dependency_build(&protocol, &Ot2TargetProfile::default(), &inventory()) + compile_dependency_build(&protocol, &Ot2AdapterProfile::default(), &inventory()) .unwrap(); assert_eq!(bundle.manifest.status, DependencyBuildStatus::Complete); assert_eq!(bundle.manifest.roots, ["final_host"]); @@ -326,7 +366,7 @@ workflow build_final_host( .unwrap(); let bundle = compile_dependency_build( &protocol, - &Ot2TargetProfile::default(), + &Ot2AdapterProfile::default(), &BuildInventory::default(), ) .unwrap(); @@ -346,11 +386,12 @@ workflow build_final_host( .select_protocol() .unwrap(); let mut inventory = inventory(); - inventory.available_artifacts.insert("intermediate".into()); - inventory.available_materials.remove("source_part"); - inventory.available_materials.remove("carrier"); + let legacy = inventory.as_legacy_mut().unwrap(); + legacy.available_artifacts.insert("intermediate".into()); + legacy.available_materials.remove("source_part"); + legacy.available_materials.remove("carrier"); let bundle = - compile_dependency_build(&protocol, &Ot2TargetProfile::default(), &inventory).unwrap(); + compile_dependency_build(&protocol, &Ot2AdapterProfile::default(), &inventory).unwrap(); assert_eq!(bundle.manifest.status, DependencyBuildStatus::Complete); let intermediate = bundle .manifest @@ -381,7 +422,7 @@ workflow build_final_host( .select_protocol() .unwrap(); let bundle = - compile_dependency_build(&protocol, &Ot2TargetProfile::default(), &inventory()) + compile_dependency_build(&protocol, &Ot2AdapterProfile::default(), &inventory()) .unwrap(); assert_eq!(bundle.manifest.status, DependencyBuildStatus::Partial); assert!( @@ -395,10 +436,12 @@ workflow build_final_host( let mut inventory = inventory(); inventory + .as_legacy_mut() + .unwrap() .available_artifacts .insert("final_artifact".into()); let bundle = - compile_dependency_build(&protocol, &Ot2TargetProfile::default(), &inventory).unwrap(); + compile_dependency_build(&protocol, &Ot2AdapterProfile::default(), &inventory).unwrap(); assert_eq!(bundle.manifest.status, DependencyBuildStatus::Complete); assert_eq!( bundle diff --git a/crates/lab-compiler/src/backend/opentrons/ot2/plan/build.rs b/crates/lab-compiler/src/backend/opentrons/ot2/plan/build.rs index 3108bb0..93650ca 100644 --- a/crates/lab-compiler/src/backend/opentrons/ot2/plan/build.rs +++ b/crates/lab-compiler/src/backend/opentrons/ot2/plan/build.rs @@ -5,7 +5,7 @@ use std::collections::{BTreeMap, BTreeSet}; use pliron::context::Context; use crate::ProtocolLairProgram; -use crate::backend::opentrons::ot2::profile::{Ot2TargetProfile, Plates, Stages}; +use crate::backend::opentrons::ot2::profile::{Ot2AdapterProfile, Plates, Stages}; use crate::backend::opentrons::ot2::BACKEND; use crate::backend::opentrons::ot2::plan::constraints::{ @@ -29,14 +29,14 @@ use crate::backend::trace::{AssemblyTrace, ProtocolTraces, StrainTrace, analyze_ /// Markdown, and JSON emitters. pub fn plan_build( protocol: &ProtocolLairProgram, - profile: &Ot2TargetProfile, + profile: &Ot2AdapterProfile, ) -> Result { plan_selected_build(protocol, profile, None) } pub(in crate::backend::opentrons::ot2) fn plan_selected_build( protocol: &ProtocolLairProgram, - profile: &Ot2TargetProfile, + profile: &Ot2AdapterProfile, selected_artifacts: Option<&BTreeSet>, ) -> Result { let context = protocol.context(); @@ -74,9 +74,9 @@ pub(in crate::backend::opentrons::ot2) fn plan_selected_build( assign_all_source_wells(&traces, context, rack_capacity)?; Ok(Ot2ExecutionPlan { - schema_version: "lab.automation.v0".into(), - target: BACKEND.into(), - api_level: profile.target.api_level.clone(), + schema_version: "lab.automation.v1".into(), + adapter: BACKEND.into(), + api_level: profile.protocol.api_level.clone(), deck: profile.clone(), assembly_source_wells, transformation_source_wells, @@ -98,9 +98,8 @@ fn validate_traces(traces: &ProtocolTraces, context: &Context) -> Result<(), Ot2 validate_uniform_batch_settings(&traces.strains, context) } -/// Rejects a target profile that declares labware in a well count this -/// backend has no row/column layout for. -fn require_deck_geometry(profile: &Ot2TargetProfile) -> Result<(), Ot2PlanningError> { +/// Rejects adapter configuration that declares labware in a well count this implementation has no row/column layout for. +fn require_deck_geometry(profile: &Ot2AdapterProfile) -> Result<(), Ot2PlanningError> { let deck = &profile.deck; let stages = &profile.stages; require_known_geometry("the source rack", deck.temperature_module.capacity)?; @@ -446,7 +445,7 @@ workflow build_reporter_host( #[test] fn allocates_both_stages_against_the_reference_bench() { let protocol = protocol(SOURCE); - let profile = Ot2TargetProfile::default(); + let profile = Ot2AdapterProfile::default(); let plan = plan_build(&protocol, &profile).unwrap(); let bundle = compile_build(&protocol, &profile).unwrap(); @@ -475,7 +474,7 @@ workflow build_reporter_host( " assembly_replicates = 1\n", " assembly_replicates = 1\n reaction_volume = 30 uL\n part_volume = 3 uL\n assembly_cycles = 40\n", ); - let plan = plan_build(&protocol(&tuned), &Ot2TargetProfile::default()).unwrap(); + let plan = plan_build(&protocol(&tuned), &Ot2AdapterProfile::default()).unwrap(); let chemistry = &plan.assemblies[0].chemistry; assert_eq!(chemistry.reaction_volume_ul, 30); @@ -494,7 +493,7 @@ workflow build_reporter_host( "buy restriction_enzyme BsaI\n", "buy restriction_enzyme BsaI:\n digest_temperature = 55 C\n digest_duration = 7 min\n", ); - let plan = plan_build(&protocol(&owned), &Ot2TargetProfile::default()).unwrap(); + let plan = plan_build(&protocol(&owned), &Ot2AdapterProfile::default()).unwrap(); let chemistry = &plan.assemblies[0].chemistry; assert_eq!(chemistry.digest_temperature_c, 55); @@ -514,7 +513,7 @@ workflow build_reporter_host( " assembly_replicates = 1\n", " assembly_replicates = 1\n digest_temperature = 30 C\n", ); - let plan = plan_build(&protocol(&owned), &Ot2TargetProfile::default()).unwrap(); + let plan = plan_build(&protocol(&owned), &Ot2AdapterProfile::default()).unwrap(); assert_eq!(plan.assemblies[0].chemistry.digest_temperature_c, 30); } @@ -534,7 +533,7 @@ workflow build_reporter_host( /// A method declares the unit each of its quantities is measured in, so a /// thousandfold error is caught where it is written rather than when a - /// target reads it. + /// adapter reads it. #[test] fn rejects_a_chemistry_quantity_in_the_wrong_unit() { let wrong = SOURCE.replace( @@ -556,7 +555,7 @@ workflow build_reporter_host( let crowded = SOURCE.replace(" plating_replicates = 2", " plating_replicates = 8"); let protocol = protocol(&crowded); - let mut single = Ot2TargetProfile::default(); + let mut single = Ot2AdapterProfile::default(); single.stages.plating.agar_plate.slots = vec!["5".to_owned()]; single.stages.plating.small_tips.slots = vec!["9".to_owned(), "10".to_owned()]; let plan = plan_build(&protocol, &single).unwrap(); diff --git a/crates/lab-compiler/src/backend/opentrons/ot2/plan/bundle.rs b/crates/lab-compiler/src/backend/opentrons/ot2/plan/bundle.rs index 414ddc0..c70ca20 100644 --- a/crates/lab-compiler/src/backend/opentrons/ot2/plan/bundle.rs +++ b/crates/lab-compiler/src/backend/opentrons/ot2/plan/bundle.rs @@ -9,7 +9,7 @@ use crate::backend::opentrons::ot2::emit::{ }; use crate::backend::{markdown, typst}; -use crate::backend::opentrons::ot2::profile::Ot2TargetProfile; +use crate::backend::opentrons::ot2::profile::Ot2AdapterProfile; use crate::backend::opentrons::ot2::plan::{ Ot2BuildError, Ot2EmissionError, Ot2ExecutionPlan, plan_build, @@ -130,7 +130,7 @@ impl Ot2Bundle { pub fn compile_build( protocol: &ProtocolLairProgram, - profile: &Ot2TargetProfile, + profile: &Ot2AdapterProfile, ) -> Result { Ok(Ot2Bundle::from_plan(plan_build(protocol, profile)?)?) } diff --git a/crates/lab-compiler/src/backend/opentrons/ot2/plan/constraints.rs b/crates/lab-compiler/src/backend/opentrons/ot2/plan/constraints.rs index 414e040..812faf4 100644 --- a/crates/lab-compiler/src/backend/opentrons/ot2/plan/constraints.rs +++ b/crates/lab-compiler/src/backend/opentrons/ot2/plan/constraints.rs @@ -1,6 +1,6 @@ //! OT-2-specific parameter and batch-capacity validation. -use crate::backend::TargetConstraintError; +use crate::backend::AdapterConstraintError; use crate::backend::opentrons::ot2::BACKEND; use crate::backend::opentrons::ot2::plan::Ot2PlanningError; @@ -24,8 +24,8 @@ pub(super) fn validate_assembly_constraints( + trace.chemistry(context, "enzyme_volume_ul") + trace.chemistry(context, "part_volume_ul") * dna_pieces; if required_ul > reaction_volume_ul { - return Err(TargetConstraintError::CapacityExceeded { - target: BACKEND.into(), + return Err(AdapterConstraintError::CapacityExceeded { + adapter: BACKEND.into(), operation: "assembly".into(), subject: artifact, resource: "reaction_volume".into(), @@ -64,8 +64,8 @@ fn require_range( maximum: u8, ) -> Result<(), Ot2PlanningError> { if !(1..=maximum).contains(&value) { - return Err(TargetConstraintError::ParameterOutOfRange { - target: BACKEND.into(), + return Err(AdapterConstraintError::ParameterOutOfRange { + adapter: BACKEND.into(), subject: artifact.to_owned(), parameter: parameter.into(), minimum: 1, @@ -96,8 +96,8 @@ pub(super) fn validate_uniform_batch_settings( trace.serial_dilutions(context), ) != expected }) { - Err(TargetConstraintError::NonUniformParameters { - target: BACKEND.into(), + Err(AdapterConstraintError::NonUniformParameters { + adapter: BACKEND.into(), subject: "automation_batch".into(), parameters: vec![ "transformation_replicates".into(), @@ -117,8 +117,8 @@ pub(super) fn plate_capacity_error( required: usize, capacity: usize, ) -> Ot2PlanningError { - TargetConstraintError::CapacityExceeded { - target: BACKEND.into(), + AdapterConstraintError::CapacityExceeded { + adapter: BACKEND.into(), operation: stage.into(), subject: "automation_batch".into(), resource: resource.into(), @@ -136,8 +136,8 @@ pub(super) fn require_tip_capacity( capacity: usize, ) -> Result<(), Ot2PlanningError> { if required > capacity { - Err(TargetConstraintError::CapacityExceeded { - target: BACKEND.into(), + Err(AdapterConstraintError::CapacityExceeded { + adapter: BACKEND.into(), operation: stage.into(), subject: "automation_batch".into(), resource: format!("{pipette}_tip_rack"), diff --git a/crates/lab-compiler/src/backend/opentrons/ot2/plan/error.rs b/crates/lab-compiler/src/backend/opentrons/ot2/plan/error.rs index e49b7e8..9803bf2 100644 --- a/crates/lab-compiler/src/backend/opentrons/ot2/plan/error.rs +++ b/crates/lab-compiler/src/backend/opentrons/ot2/plan/error.rs @@ -1,18 +1,18 @@ use thiserror::Error; use crate::ArtifactError; -use crate::backend::TargetConstraintError; +use crate::backend::AdapterConstraintError; #[derive(Debug, Error, PartialEq, Eq)] pub enum Ot2PlanningError { #[error(transparent)] - Constraint(Box), - #[error("invalid target-selected Protocol LAIR: {0}")] + Constraint(Box), + #[error("invalid method-selected Protocol LAIR: {0}")] InvalidProtocol(String), } -impl From for Ot2PlanningError { - fn from(error: TargetConstraintError) -> Self { +impl From for Ot2PlanningError { + fn from(error: AdapterConstraintError) -> Self { Self::Constraint(Box::new(error)) } } diff --git a/crates/lab-compiler/src/backend/opentrons/ot2/plan/execution.rs b/crates/lab-compiler/src/backend/opentrons/ot2/plan/execution.rs index a5559fc..093fb3b 100644 --- a/crates/lab-compiler/src/backend/opentrons/ot2/plan/execution.rs +++ b/crates/lab-compiler/src/backend/opentrons/ot2/plan/execution.rs @@ -4,7 +4,7 @@ use std::collections::BTreeMap; use serde::Serialize; -use crate::backend::opentrons::ot2::profile::Ot2TargetProfile; +use crate::backend::opentrons::ot2::profile::Ot2AdapterProfile; pub use crate::backend::resources::Well as Ot2Well; @@ -15,13 +15,11 @@ pub use crate::backend::resources::Well as Ot2Well; #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct Ot2ExecutionPlan { pub schema_version: String, - /// The backend that produced this plan, spelled exactly as a target - /// profile declares it. - pub target: String, + /// The explicit adapter implementation that produced this device plan. + pub adapter: String, pub api_level: String, - /// The bench this plan was allocated against. Emission reads every labware - /// name, deck slot, and mount from here rather than from a constant. - pub deck: Ot2TargetProfile, + /// Checked implementation configuration for the allocated Asset binding. + pub deck: Ot2AdapterProfile, pub assembly_source_wells: BTreeMap, pub transformation_source_wells: BTreeMap, /// DNA-plate well holding each plasmid a strain is transformed from. A diff --git a/crates/lab-compiler/src/backend/opentrons/ot2/profile/defaults.rs b/crates/lab-compiler/src/backend/opentrons/ot2/profile/defaults.rs index d2d29ae..9479165 100644 --- a/crates/lab-compiler/src/backend/opentrons/ot2/profile/defaults.rs +++ b/crates/lab-compiler/src/backend/opentrons/ot2/profile/defaults.rs @@ -1,21 +1,10 @@ //! Reference-bench default values for every profile field. -use crate::backend::opentrons::ot2::BACKEND; use crate::backend::opentrons::ot2::profile::schema::{ AssemblyStage, MediaRack, Pipette, Plates, PlatingStage, TemperatureModule, Thermocycler, TipRacks, TransformationStage, }; -/// Bench name for a build that named no profile: the reference bench this -/// backend was developed against. -pub(super) fn default_bench_name() -> String { - "reference-bench".to_owned() -} - -pub(super) fn default_backend() -> String { - BACKEND.to_owned() -} - pub(super) fn default_api_level() -> String { "2.21".to_owned() } diff --git a/crates/lab-compiler/src/backend/opentrons/ot2/profile/error.rs b/crates/lab-compiler/src/backend/opentrons/ot2/profile/error.rs index 21af30b..bc017d6 100644 --- a/crates/lab-compiler/src/backend/opentrons/ot2/profile/error.rs +++ b/crates/lab-compiler/src/backend/opentrons/ot2/profile/error.rs @@ -1,16 +1,11 @@ -//! Errors from parsing and validating an OT-2 target profile. +//! Errors from parsing and validating OT-2 adapter configuration. use thiserror::Error; #[derive(Debug, Error)] pub enum Ot2ProfileError { - #[error("failed to parse OT-2 target profile: {0}")] + #[error("failed to parse OT-2 adapter profile: {0}")] Parse(#[from] toml::de::Error), - #[error("target profile declares backend '{found}', but this backend is '{expected}'")] - WrongBackend { - expected: &'static str, - found: String, - }, #[error("{context} names deck slot '{slot}', which an OT-2 does not address")] UnknownSlot { context: String, slot: String }, #[error( diff --git a/crates/lab-compiler/src/backend/opentrons/ot2/profile/mod.rs b/crates/lab-compiler/src/backend/opentrons/ot2/profile/mod.rs index dc16371..dd4657f 100644 --- a/crates/lab-compiler/src/backend/opentrons/ot2/profile/mod.rs +++ b/crates/lab-compiler/src/backend/opentrons/ot2/profile/mod.rs @@ -1,14 +1,8 @@ -//! Site configuration for one OT-2 bench. +//! Operational configuration for the Opentrons OT-2 adapter. //! -//! A profile describes the laboratory, not the science: which modules are -//! installed, which labware sits in which deck slot, and which pipette is on -//! which mount. Two laboratories running the same Lab program supply different -//! profiles; neither program changes. +//! Facility allocation has already selected an exact Asset before this profile is read. The profile contains only checked configuration the implementation still needs to produce an executable protocol. It cannot select a facility Asset or another adapter. //! -//! Every field has a default, so a profile states only what differs from the -//! bench this backend was developed against. Unknown keys are rejected, because -//! a misspelled slot silently falling back to a default is how a protocol ends -//! up aspirating from the wrong place. +//! Every field has a default, so a profile states only what differs from the reference implementation configuration. Unknown keys are rejected, because a misspelled slot silently falling back to a default is how a protocol ends up aspirating from the wrong place. mod defaults; mod error; @@ -19,11 +13,10 @@ use std::collections::BTreeSet; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::backend::opentrons::ot2::BACKEND; pub use error::Ot2ProfileError; // Only the field types other `ot2` submodules reach into directly -// are re-exported; the rest of the schema stays behind `Ot2TargetProfile`. -use schema::{Instruments, SharedDeck, TargetMetadata}; +// are re-exported; the rest of the schema stays behind `Ot2AdapterProfile`. +use schema::{Instruments, ProtocolOptions, SharedDeck}; pub use schema::{Plates, Stages}; /// Deck slots an OT-2 can address. Slot 12 is the fixed trash. @@ -31,12 +24,16 @@ const ADDRESSABLE_SLOTS: [&str; 11] = ["1", "2", "3", "4", "5", "6", "7", "8", " /// Slots the Thermocycler Module GEN2 occupies when installed. const THERMOCYCLER_SLOTS: [&str; 4] = ["7", "8", "10", "11"]; -/// The complete OT-2 site configuration consumed by planning and emission. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +/// The complete OT-2 implementation configuration consumed by planning and emission. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] -pub struct Ot2TargetProfile { +pub struct Ot2AdapterProfile { + /// File-stem label supplied by the exact Asset binding. It is review metadata, not profile input. + #[serde(skip)] + #[schemars(skip)] + pub name: String, #[serde(default)] - pub target: TargetMetadata, + pub protocol: ProtocolOptions, #[serde(default)] pub instruments: Instruments, #[serde(default)] @@ -45,24 +42,28 @@ pub struct Ot2TargetProfile { pub stages: Stages, } -impl Ot2TargetProfile { - /// Load the profile named `name`. The name is the profile's filename under - /// `targets/`, supplied by whoever resolved it, so the file itself never - /// states which bench it is. +impl Default for Ot2AdapterProfile { + fn default() -> Self { + Self { + name: "opentrons.ot2".to_owned(), + protocol: ProtocolOptions::default(), + instruments: Instruments::default(), + deck: SharedDeck::default(), + stages: Stages::default(), + } + } +} + +impl Ot2AdapterProfile { + /// Load operational configuration for one exact Asset binding. pub fn parse(name: &str, text: &str) -> Result { let mut profile: Self = toml::from_str(text)?; - profile.target.name = name.to_owned(); + profile.name = name.to_owned(); profile.validate()?; Ok(profile) } pub fn validate(&self) -> Result<(), Ot2ProfileError> { - if self.target.backend != BACKEND { - return Err(Ot2ProfileError::WrongBackend { - expected: BACKEND, - found: self.target.backend.clone(), - }); - } for (stage, claims) in [ ("assembly", self.assembly_claims()), ("transformation", self.transformation_claims()), @@ -177,8 +178,9 @@ mod tests { #[test] fn an_empty_profile_describes_the_reference_bench() { - let profile = Ot2TargetProfile::parse("reference-bench", "").unwrap(); - assert_eq!(profile, Ot2TargetProfile::default()); + let profile = Ot2AdapterProfile::parse("reference-bench", "").unwrap(); + assert_eq!(profile.name, "reference-bench"); + assert_eq!(profile.protocol.api_level, "2.21"); assert_eq!(profile.deck.temperature_module.slot, "1"); assert_eq!(profile.stages.plating.agar_plate.slots, ["5", "6"]); assert_eq!(profile.stages.plating.agar_plate.total_capacity(), 192); @@ -186,7 +188,7 @@ mod tests { #[test] fn a_profile_overrides_only_what_it_states() { - let profile = Ot2TargetProfile::parse( + let profile = Ot2AdapterProfile::parse( "bench-two", r#" [stages.plating.agar_plate] @@ -205,28 +207,24 @@ capacity = 96 } #[test] - fn the_loader_names_the_bench_and_the_file_may_not() { - let profile = Ot2TargetProfile::parse("bench-two", "[target]\napi_level = \"2.20\"\n") - .expect("a profile is named by the file it was loaded from"); - assert_eq!(profile.target.name, "bench-two"); - assert_eq!(profile.target.backend, BACKEND); - - let error = Ot2TargetProfile::parse("bench-two", "[target]\nname = \"bench-three\"\n") - .expect_err("a profile that renames itself could disagree with its filename"); - assert!(error.to_string().contains("name"), "{error}"); + fn the_loader_supplies_the_profile_name_and_protocol_options_are_explicit() { + let profile = Ot2AdapterProfile::parse("ot2-runtime", "[protocol]\napi_level = \"2.20\"\n") + .expect("the exact Asset binding supplies the profile label"); + assert_eq!(profile.name, "ot2-runtime"); + assert_eq!(profile.protocol.api_level, "2.20"); } #[test] - fn rejects_a_profile_written_for_another_backend() { + fn rejects_an_embedded_target_or_adapter_selector() { let error = - Ot2TargetProfile::parse("bench-two", "[target]\nbackend = \"opentrons.flex\"\n") - .expect_err("this backend compiles only its own profiles"); - assert!(error.to_string().contains(BACKEND), "{error}"); + Ot2AdapterProfile::parse("ot2-runtime", "[target]\nbackend = \"opentrons.flex\"\n") + .expect_err("only the exact Asset binding may select an adapter"); + assert!(error.to_string().contains("target"), "{error}"); } #[test] fn rejects_labware_placed_under_the_thermocycler() { - let error = Ot2TargetProfile::parse( + let error = Ot2AdapterProfile::parse( "bench-two", r#" [stages.assembly.small_tips] @@ -241,7 +239,7 @@ capacity = 96 #[test] fn rejects_two_labware_in_one_slot_during_a_stage() { - let error = Ot2TargetProfile::parse( + let error = Ot2AdapterProfile::parse( "bench-two", r#" [stages.plating.agar_plate] @@ -256,7 +254,7 @@ capacity = 96 #[test] fn rejects_an_unknown_key_rather_than_silently_ignoring_it() { - let error = Ot2TargetProfile::parse("bench-two", "[stages.plating]\nagar_plates = 2\n") + let error = Ot2AdapterProfile::parse("bench-two", "[stages.plating]\nagar_plates = 2\n") .expect_err("a misspelled key must not fall back to a default"); assert!(error.to_string().contains("parse"), "{error}"); } diff --git a/crates/lab-compiler/src/backend/opentrons/ot2/profile/schema.rs b/crates/lab-compiler/src/backend/opentrons/ot2/profile/schema.rs index 1cab95b..4e1f657 100644 --- a/crates/lab-compiler/src/backend/opentrons/ot2/profile/schema.rs +++ b/crates/lab-compiler/src/backend/opentrons/ot2/profile/schema.rs @@ -1,5 +1,4 @@ -//! Deserializable shape of an OT-2 target profile: instruments, deck -//! modules, and the labware each build stage claims. +//! Deserializable shape of OT-2 adapter configuration: protocol options, instruments, deck modules, and the labware each build stage claims. use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -10,24 +9,15 @@ use crate::backend::opentrons::ot2::profile::defaults::*; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] -pub struct TargetMetadata { - /// The bench this profile describes, named by whoever loaded it: a profile - /// is selected as `targets/.toml`, so the file does not repeat its - /// own name and cannot disagree with it. Emitted plans carry the name so - /// an operator can see which bench a protocol was compiled for. - #[serde(skip_deserializing, default = "default_bench_name")] - pub name: String, - #[serde(default = "default_backend")] - pub backend: String, +pub struct ProtocolOptions { + /// Opentrons Python Protocol API version emitted by this adapter. #[serde(default = "default_api_level")] pub api_level: String, } -impl Default for TargetMetadata { +impl Default for ProtocolOptions { fn default() -> Self { Self { - name: default_bench_name(), - backend: default_backend(), api_level: default_api_level(), } } diff --git a/crates/lab-compiler/src/backend/opentrons/ot2/python/README.md b/crates/lab-compiler/src/backend/opentrons/ot2/python/README.md index bd4b81d..1763adc 100644 --- a/crates/lab-compiler/src/backend/opentrons/ot2/python/README.md +++ b/crates/lab-compiler/src/backend/opentrons/ot2/python/README.md @@ -4,16 +4,16 @@ This directory is the maintained Python implementation of Lab's Opentrons OT-2 b The maintained modules import `Ot2ExecutionPlan` unconditionally from `plan_types.py` and are always typechecked. During compiler emission, Rust deterministically replaces that package import with the same marked `TypedDict` definitions and injects the serialized execution plan. The generated protocol therefore remains a standalone file accepted by the robot while preserving the checked source's types and behavior. -The Python and Opentrons versions are pinned in `uv.lock`. Run every Python target gate with: +The Python and Opentrons versions are pinned in `uv.lock`. Run every Python adapter gate with: ```sh -scripts/check-opentrons-target.sh +scripts/check-opentrons-bundle.sh ``` After generating `.lab/full-build`, also lint, typecheck, and byte-compile every emitted protocol: ```sh -scripts/check-opentrons-target.sh .lab/full-build +scripts/check-opentrons-bundle.sh .lab/full-build scripts/simulate-opentrons.sh .lab/full-build ``` diff --git a/crates/lab-compiler/src/backend/opentrons/ot2/python/pyproject.toml b/crates/lab-compiler/src/backend/opentrons/ot2/python/pyproject.toml index 787eb24..b9fa9c0 100644 --- a/crates/lab-compiler/src/backend/opentrons/ot2/python/pyproject.toml +++ b/crates/lab-compiler/src/backend/opentrons/ot2/python/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "lab-opentrons-ot2-target" +name = "lab-opentrons-ot2-adapter" version = "0.1.0" description = "Maintained Python implementation for Lab's Opentrons OT-2 backend" requires-python = ">=3.12,<3.13" diff --git a/crates/lab-compiler/src/backend/opentrons/ot2/python/src/lab_opentrons_ot2/plan_types.py b/crates/lab-compiler/src/backend/opentrons/ot2/python/src/lab_opentrons_ot2/plan_types.py index bdd0a0b..0c3886a 100644 --- a/crates/lab-compiler/src/backend/opentrons/ot2/python/src/lab_opentrons_ot2/plan_types.py +++ b/crates/lab-compiler/src/backend/opentrons/ot2/python/src/lab_opentrons_ot2/plan_types.py @@ -69,14 +69,12 @@ class Stages(TypedDict): plating: PlatingStage -class TargetMetadata(TypedDict): - name: str - backend: str +class ProtocolOptions(TypedDict): api_level: str -class TargetProfile(TypedDict): - target: TargetMetadata +class AdapterProfile(TypedDict): + protocol: ProtocolOptions instruments: Instruments deck: SharedDeck stages: Stages @@ -154,9 +152,9 @@ class StrainPlan(TypedDict): class Ot2ExecutionPlan(TypedDict): schema_version: str - target: str + adapter: str api_level: str - deck: TargetProfile + deck: AdapterProfile assembly_source_wells: dict[str, str] transformation_source_wells: dict[str, str] dna_source_wells: dict[str, Well] diff --git a/crates/lab-compiler/src/backend/opentrons/ot2/python/uv.lock b/crates/lab-compiler/src/backend/opentrons/ot2/python/uv.lock index 66bdc36..29d76b2 100644 --- a/crates/lab-compiler/src/backend/opentrons/ot2/python/uv.lock +++ b/crates/lab-compiler/src/backend/opentrons/ot2/python/uv.lock @@ -95,7 +95,7 @@ wheels = [ ] [[package]] -name = "lab-opentrons-ot2-target" +name = "lab-opentrons-ot2-adapter" version = "0.1.0" source = { virtual = "." } dependencies = [ diff --git a/crates/lab-compiler/src/backend/package.rs b/crates/lab-compiler/src/backend/package.rs index d2aa818..ba79113 100644 --- a/crates/lab-compiler/src/backend/package.rs +++ b/crates/lab-compiler/src/backend/package.rs @@ -4,7 +4,7 @@ use std::collections::BTreeSet; use crate::backend::document::{Block, Column, Doc, DocMeta, code, text}; -use crate::planning::{ArtifactResolution, DependencyBuildManifest}; +use crate::planning::{ArtifactResolution, DependencyBuildManifest, DependencyInventorySource}; /// One robot run in execution order: run index, planning iteration, artifact /// label, package directory, and the run's own manual content, spliced into @@ -25,15 +25,15 @@ pub(in crate::backend) fn render_full_build_instructions( "Generated concept protocol. Review and qualify every run for the actual laboratory before execution. Planning success is not physical-build or acceptance evidence.", )]); - let profile_name = doc.meta.target.clone(); + let profile_name = doc.meta.adapter_profile.clone(); doc.heading(1, [text("How this package fits together")]); doc.para([ text("The Lab toolchain compiled this package from the project's "), code(".lab"), text(" sources against the "), code(profile_name), - text(" bench profile. Every artifact volume, well address, and deck position in this document was planned at compile time, and the robot files, the machine-readable manifests, and this document are all projections of the same execution plan, so they cannot disagree with one another. Nothing here is meant to be edited by hand: to change what a run does, change the sources or the target profile and run "), - code("lab build"), + text(" allocated facility adapter. Every artifact volume, well address, and deck position in this document was planned at compile time, and the robot files, the machine-readable manifests, and this document are all projections of the same execution plan, so they cannot disagree with one another. Nothing here is meant to be edited by hand: to change what a run does, change the sources, facility inventory, or exact Asset-to-adapter configuration and run "), + code("lab plan"), text(" again."), ]); doc.bullets([ @@ -187,6 +187,18 @@ pub(in crate::backend) fn render_report(meta: DocMeta, manifest: &DependencyBuil let mut doc = Doc::new(meta); doc.para([text("Status: "), code(format!("{:?}", manifest.status))]); doc.para_text(format!("Roots: {}", manifest.roots.join(", "))); + match &manifest.inventory { + DependencyInventorySource::SbolInventory { + source_sha256, + facility, + } => { + doc.para([text("Facility: "), code(facility)]); + doc.para([text("Inventory source SHA-256: "), code(source_sha256)]); + } + DependencyInventorySource::LegacySymbols => { + doc.para_text("Inventory source: legacy symbolic manifest arrays."); + } + } doc.table( [ Column::left("Artifact"), @@ -206,6 +218,43 @@ pub(in crate::backend) fn render_report(meta: DocMeta, manifest: &DependencyBuil ] }), ); + let lot_rows = manifest + .nodes + .iter() + .flat_map(|node| { + node.existing_material_lot + .iter() + .map(|binding| { + vec![ + vec![text(node.artifact.as_str())], + vec![text("existing artifact")], + vec![code(binding.component.as_str())], + vec![code(binding.material_lot.as_str())], + ] + }) + .chain(node.material_lot_bindings.iter().map(|binding| { + vec![ + vec![text(node.artifact.as_str())], + vec![code(binding.symbol.as_str())], + vec![code(binding.component.as_str())], + vec![code(binding.material_lot.as_str())], + ] + })) + .collect::>() + }) + .collect::>(); + if !lot_rows.is_empty() { + doc.heading(1, [text("Material lot bindings")]); + doc.table( + [ + Column::left("Artifact"), + Column::left("Use"), + Column::left("SBOL Component"), + Column::left("MaterialLot"), + ], + lot_rows, + ); + } let blockers = manifest .nodes .iter() diff --git a/crates/lab-compiler/src/backend/resources.rs b/crates/lab-compiler/src/backend/resources.rs index a92d0e2..7b32690 100644 --- a/crates/lab-compiler/src/backend/resources.rs +++ b/crates/lab-compiler/src/backend/resources.rs @@ -4,14 +4,14 @@ use std::collections::{BTreeMap, BTreeSet}; use serde::Serialize; -use crate::backend::TargetConstraintError; +use crate::backend::AdapterConstraintError; use crate::backend::error::PlanningError; use crate::backend::profile::Plates; use crate::backend::trace::{AssemblyTrace, StrainTrace}; /// A well on one of the plates a stage may hold several of. `plate` indexes -/// the stage's declared slot list, so adding a slot to a target profile raises +/// the stage's declared slot list, so adding a slot to adapter configuration raises /// the build's capacity without changing any address already assigned. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct Well { @@ -61,8 +61,8 @@ pub(in crate::backend) fn assign_source_wells( capacity: usize, ) -> Result, PlanningError> { if keys.len() > capacity { - return Err(TargetConstraintError::CapacityExceeded { - target: backend.into(), + return Err(AdapterConstraintError::CapacityExceeded { + adapter: backend.into(), operation: stage.into(), subject: "automation_batch".into(), resource: "source_rack".into(), @@ -112,8 +112,8 @@ impl<'a> PlateAllocator<'a> { fn next_well(&mut self) -> Result { let capacity = self.plates.total_capacity(); if self.cursor >= capacity { - return Err(TargetConstraintError::CapacityExceeded { - target: self.backend.into(), + return Err(AdapterConstraintError::CapacityExceeded { + adapter: self.backend.into(), operation: self.stage.into(), subject: "automation_batch".into(), resource: self.resource.into(), @@ -162,7 +162,7 @@ pub(in crate::backend) fn require_known_geometry( ) -> Result<(), PlanningError> { if plate_wells(capacity).is_empty() { return Err(PlanningError::InvalidProtocol(format!( - "target profile gives {resource} {capacity} wells, which is not a labware format this backend can address" + "adapter configuration gives {resource} {capacity} wells, which is not a labware format this implementation can address" ))); } Ok(()) diff --git a/crates/lab-compiler/src/backend/target_profiles.rs b/crates/lab-compiler/src/backend/target_profiles.rs deleted file mode 100644 index 8fbb805..0000000 --- a/crates/lab-compiler/src/backend/target_profiles.rs +++ /dev/null @@ -1,583 +0,0 @@ -//! Machine-readable target-profile discovery and validation. -//! -//! This module is the compiler-owned contract for control planes and editors. -//! Consumers discover schemas, defaults, catalog choices, and station kinds -//! here instead of copying Rust profile structs into another codebase. - -use std::collections::BTreeSet; - -use schemars::{JsonSchema, schema_for}; -use serde::Serialize; -use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; -use thiserror::Error; - -use crate::backend::hamilton::star::catalog::{CARRIERS, LABWARE, LabwareRole}; -use crate::backend::hamilton::star::{StarBackend, StarTargetProfile}; -use crate::backend::opentrons::flex::{FlexBackend, FlexTargetProfile}; -use crate::backend::opentrons::ot2::{Ot2Backend, Ot2TargetProfile}; -use crate::backend::workcell::{StationKind, WorkcellProfile}; -use crate::backend::{Backend, BackendDescriptor}; - -pub const CAPABILITIES_FORMAT: &str = "lab.target-capabilities.v0"; -pub const PROFILE_SCHEMA_VERSION: &str = "lab.target-profile.v0"; -pub const VALIDATION_FORMAT: &str = "lab.target-profile-validation.v0"; - -pub const KNOWN_BACKENDS: [&str; 4] = [ - "opentrons.ot2", - "opentrons.flex", - "hamilton.star", - "workcell", -]; - -/// All target schemas and station kinds shipped by this compiler build. -#[derive(Clone, Debug, Serialize)] -pub struct TargetCapabilitiesDocument { - pub format: &'static str, - pub compiler_version: &'static str, - pub profile_schema_version: &'static str, - pub targets: Vec, - pub station_kinds: Vec, -} - -/// One concrete value accepted by `[target] backend`. -#[derive(Clone, Debug, Serialize)] -pub struct TargetCapability { - pub backend: &'static str, - pub display_name: String, - pub manufacturer: Option, - pub kind: TargetKind, - pub capabilities: BTreeSet, - pub schema: Value, - pub default_profile: ValidatedTargetProfile, - /// Editor hints backed by the same catalogs and validators planning uses. - pub catalog: Value, -} - -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum TargetKind { - LiquidHandler, - Workcell, -} - -/// A station kind a workcell may declare. -#[derive(Clone, Debug, Serialize)] -pub struct StationCapability { - pub kind: &'static str, - pub display_name: &'static str, - pub manufacturer: &'static str, - pub capabilities: BTreeSet, - pub runtime_executor: bool, - pub planner_assigns_work: bool, -} - -/// The canonical result of parsing and semantically validating one profile. -#[derive(Clone, Debug, Serialize)] -pub struct ValidatedTargetProfile { - pub format: &'static str, - pub schema_version: &'static str, - pub compiler_version: &'static str, - pub name: String, - pub backend: &'static str, - pub canonical_toml: String, - pub canonical_json: Value, - pub sha256: String, -} - -#[derive(Debug, Error)] -pub enum TargetProfileContractError { - #[error("failed to parse target profile header: {0}")] - Header(#[from] toml::de::Error), - #[error( - "target profile declares backend '{found}', which this compiler does not provide; known backends are {known}" - )] - UnknownBackend { found: String, known: String }, - #[error("invalid {backend} target profile: {message}")] - Invalid { - backend: &'static str, - message: String, - }, - #[error("failed to serialize the validated target profile: {0}")] - Serialize(String), -} - -pub enum TargetProfile { - Ot2(Ot2TargetProfile), - Flex(FlexTargetProfile), - Star(StarTargetProfile), - Workcell(WorkcellProfile), -} - -impl TargetProfile { - pub fn backend(&self) -> &'static str { - match self { - Self::Ot2(_) => "opentrons.ot2", - Self::Flex(_) => "opentrons.flex", - Self::Star(_) => "hamilton.star", - Self::Workcell(_) => "workcell", - } - } - - pub fn canonical( - &self, - name: &str, - ) -> Result { - match self { - Self::Ot2(profile) => canonical_profile(name, self.backend(), profile), - Self::Flex(profile) => canonical_profile(name, self.backend(), profile), - Self::Star(profile) => canonical_profile(name, self.backend(), profile), - Self::Workcell(profile) => canonical_profile(name, self.backend(), profile), - } - } -} - -/// Describe every target and station kind this exact compiler binary provides. -pub fn target_capabilities() -> Result { - Ok(TargetCapabilitiesDocument { - format: CAPABILITIES_FORMAT, - compiler_version: env!("CARGO_PKG_VERSION"), - profile_schema_version: PROFILE_SCHEMA_VERSION, - targets: vec![ - liquid_handler_capability::( - "opentrons.ot2", - Ot2Backend::default().descriptor(), - ot2_catalog(), - )?, - liquid_handler_capability::( - "opentrons.flex", - FlexBackend::default().descriptor(), - flex_catalog(), - )?, - liquid_handler_capability::( - "hamilton.star", - StarBackend::default().descriptor(), - star_catalog(), - )?, - TargetCapability { - backend: "workcell", - display_name: "Multi-station workcell".to_string(), - manufacturer: None, - kind: TargetKind::Workcell, - capabilities: BTreeSet::from([ - "coordination_plan".to_string(), - "human_handoff".to_string(), - "liquid_transfer".to_string(), - "thermocycler".to_string(), - ]), - schema: schema_value::()?, - default_profile: default_target_profile("workcell", "workcell")?, - catalog: json!({ - "station_kinds": [ - "hamilton.star", - "inheco.odtc", - "byonoy.absorbance96" - ], - "transport": ["human"], - "constraints": { - "liquid_handlers": { "kind": "hamilton.star", "exactly": 1 }, - "instruments_of_each_kind": { "at_most": 1 } - } - }), - }, - ], - station_kinds: station_capabilities(), - }) -} - -/// Parse and semantically validate a profile, returning its canonical form. -pub fn validate_target_profile( - name: &str, - contents: &str, -) -> Result { - parse_target_profile(name, contents)?.canonical(name) -} - -/// Return the complete canonical reference profile for one backend. -pub fn default_target_profile( - backend: &str, - name: &str, -) -> Result { - let profile = match backend { - "opentrons.ot2" => TargetProfile::Ot2( - Ot2TargetProfile::parse(name, "").map_err(|error| invalid("opentrons.ot2", error))?, - ), - "opentrons.flex" => TargetProfile::Flex( - FlexTargetProfile::parse(name, "").map_err(|error| invalid("opentrons.flex", error))?, - ), - "hamilton.star" => TargetProfile::Star( - StarTargetProfile::parse(name, "").map_err(|error| invalid("hamilton.star", error))?, - ), - "workcell" => TargetProfile::Workcell( - WorkcellProfile::parse( - name, - r#"[target] -backend = "workcell" - -[[station]] -name = "star-1" -kind = "hamilton.star" -profile = "hamilton-star" - -[transport] -between = "human" -"#, - ) - .map_err(|error| invalid("workcell", error))?, - ), - other => return Err(unknown_backend(other)), - }; - profile.canonical(name) -} - -pub fn parse_target_profile( - name: &str, - contents: &str, -) -> Result { - let table = contents.parse::()?; - let backend = table - .get("target") - .and_then(|target| target.get("backend")) - .and_then(toml::Value::as_str) - .unwrap_or("opentrons.ot2"); - match backend { - "opentrons.ot2" => Ot2TargetProfile::parse(name, contents) - .map(TargetProfile::Ot2) - .map_err(|error| invalid("opentrons.ot2", error)), - "opentrons.flex" => FlexTargetProfile::parse(name, contents) - .map(TargetProfile::Flex) - .map_err(|error| invalid("opentrons.flex", error)), - "hamilton.star" => StarTargetProfile::parse(name, contents) - .map(TargetProfile::Star) - .map_err(|error| invalid("hamilton.star", error)), - "workcell" => WorkcellProfile::parse(name, contents) - .map(TargetProfile::Workcell) - .map_err(|error| invalid("workcell", error)), - other => Err(unknown_backend(other)), - } -} - -fn liquid_handler_capability( - backend: &'static str, - descriptor: BackendDescriptor, - catalog: Value, -) -> Result -where - T: JsonSchema, -{ - let target = descriptor - .targets - .into_iter() - .next() - .expect("each shipped machine backend has one target descriptor"); - Ok(TargetCapability { - backend, - display_name: target.display_name, - manufacturer: descriptor.manufacturer, - kind: TargetKind::LiquidHandler, - capabilities: target.capabilities, - schema: schema_value::()?, - default_profile: default_target_profile(backend, backend)?, - catalog, - }) -} - -fn schema_value() -> Result { - let mut schema = serde_json::to_value(schema_for!(T)) - .map_err(|error| TargetProfileContractError::Serialize(error.to_string()))?; - sanitize_schema_defaults(&mut schema); - Ok(schema) -} - -/// Schemars sees the loader-supplied profile name during `Default` -/// serialization even though serde correctly omits it from deserialization. -/// Remove any such value from closed-object defaults so every advertised -/// default validates against the schema that advertises it. -fn sanitize_schema_defaults(value: &mut Value) { - let definitions = value.get("$defs").cloned().unwrap_or(Value::Null); - sanitize_schema_node(value, &definitions); -} - -fn sanitize_schema_node(value: &mut Value, definitions: &Value) { - if let Some(object) = value.as_object_mut() { - let property_names = closed_object_properties(object, definitions); - if let (Some(property_names), Some(default)) = ( - property_names, - object.get_mut("default").and_then(Value::as_object_mut), - ) { - default.retain(|name, _| property_names.contains(name)); - } - for child in object.values_mut() { - sanitize_schema_node(child, definitions); - } - } else if let Some(array) = value.as_array_mut() { - for child in array { - sanitize_schema_node(child, definitions); - } - } -} - -fn closed_object_properties( - object: &serde_json::Map, - definitions: &Value, -) -> Option> { - let closed_object = if object.get("additionalProperties") == Some(&Value::Bool(false)) { - Some(object) - } else { - object - .get("$ref") - .and_then(Value::as_str) - .and_then(|reference| reference.strip_prefix("#/$defs/")) - .and_then(|name| definitions.get(name)) - .and_then(Value::as_object) - .filter(|definition| { - definition.get("additionalProperties") == Some(&Value::Bool(false)) - }) - }?; - closed_object - .get("properties") - .and_then(Value::as_object) - .map(|properties| properties.keys().cloned().collect()) -} - -fn canonical_profile( - name: &str, - backend: &'static str, - profile: &T, -) -> Result { - let mut canonical_json = serde_json::to_value(profile) - .map_err(|error| TargetProfileContractError::Serialize(error.to_string()))?; - remove_derived_name(&mut canonical_json); - - let mut toml_value = toml::Value::try_from(profile) - .map_err(|error| TargetProfileContractError::Serialize(error.to_string()))?; - remove_derived_toml_name(&mut toml_value); - let mut canonical_toml = toml::to_string_pretty(&toml_value) - .map_err(|error| TargetProfileContractError::Serialize(error.to_string()))?; - if !canonical_toml.ends_with('\n') { - canonical_toml.push('\n'); - } - let sha256 = Sha256::digest(canonical_toml.as_bytes()) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect(); - Ok(ValidatedTargetProfile { - format: VALIDATION_FORMAT, - schema_version: PROFILE_SCHEMA_VERSION, - compiler_version: env!("CARGO_PKG_VERSION"), - name: name.to_string(), - backend, - canonical_toml, - canonical_json, - sha256, - }) -} - -fn remove_derived_name(value: &mut Value) { - if let Some(target) = value.get_mut("target").and_then(Value::as_object_mut) { - target.remove("name"); - } -} - -fn remove_derived_toml_name(value: &mut toml::Value) { - if let Some(target) = value.get_mut("target").and_then(toml::Value::as_table_mut) { - target.remove("name"); - } -} - -fn invalid(backend: &'static str, error: impl std::fmt::Display) -> TargetProfileContractError { - TargetProfileContractError::Invalid { - backend, - message: error.to_string(), - } -} - -fn unknown_backend(found: &str) -> TargetProfileContractError { - TargetProfileContractError::UnknownBackend { - found: found.to_string(), - known: KNOWN_BACKENDS - .iter() - .map(|backend| format!("'{backend}'")) - .collect::>() - .join(", "), - } -} - -fn station_capabilities() -> Vec { - vec![ - StationCapability { - kind: StationKind::HamiltonStar.as_str(), - display_name: "Hamilton STAR/STARlet", - manufacturer: "Hamilton", - capabilities: BTreeSet::from(["liquid_transfer".to_string()]), - runtime_executor: true, - planner_assigns_work: true, - }, - StationCapability { - kind: StationKind::InhecoOdtc.as_str(), - display_name: "Inheco ODTC", - manufacturer: "Inheco", - capabilities: BTreeSet::from(["thermocycler".to_string()]), - runtime_executor: true, - planner_assigns_work: true, - }, - StationCapability { - kind: StationKind::ByonoyAbsorbance96.as_str(), - display_name: "Byonoy Absorbance 96", - manufacturer: "Byonoy", - capabilities: BTreeSet::from(["absorbance_plate_read".to_string()]), - runtime_executor: false, - planner_assigns_work: false, - }, - ] -} - -fn ot2_catalog() -> Value { - json!({ - "slots": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"], - "mounts": ["left", "right"], - "reference_pipettes": ["p20_single_gen2", "p300_single_gen2"], - "reference_modules": ["temperature module gen2", "thermocycler module gen2"], - "fixed_thermocycler_slots": ["7", "8", "10", "11"] - }) -} - -fn flex_catalog() -> Value { - json!({ - "slots": ["A1", "A2", "A3", "B1", "B2", "B3", "C1", "C2", "C3", "D1", "D2", "D3"], - "mounts": ["left", "right"], - "pipettes": [ - "p50_single_flex", - "p50_multi_flex", - "p1000_single_flex", - "p1000_multi_flex", - "p1000_96" - ], - "trash_areas": [ - "movableTrashA1", "movableTrashB1", "movableTrashC1", "movableTrashD1", - "movableTrashA3", "movableTrashB3", "movableTrashC3", "movableTrashD3" - ], - "fixed_thermocycler_slots": ["A1", "B1"] - }) -} - -fn star_catalog() -> Value { - let carriers = CARRIERS - .iter() - .map(|carrier| { - json!({ - "id": carrier.id, - "display_name": carrier.hamilton_model, - "width_rails": carrier.width_rails, - "sites": carrier.sites.len() - }) - }) - .collect::>(); - let labware = LABWARE - .iter() - .map(|labware| { - json!({ - "id": labware.id, - "display_name": labware.display, - "capacity": labware.capacity, - "role": match labware.role { - LabwareRole::Vessel { .. } => "vessel", - LabwareRole::TipRack { .. } => "tip-rack", - } - }) - }) - .collect::>(); - json!({ - "machine_variants": [ - { "id": "starlet", "rails": 32 }, - { "id": "star", "rails": 56 } - ], - "channel_counts": [8], - "lld_policies": ["off", "gamma"], - "carriers": carriers, - "labware": labware - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn discovery_names_every_backend_and_station_kind() { - let document = target_capabilities().unwrap(); - assert_eq!( - document - .targets - .iter() - .map(|target| target.backend) - .collect::>(), - KNOWN_BACKENDS - ); - assert_eq!(document.station_kinds.len(), 3); - assert!( - document - .station_kinds - .iter() - .any(|station| station.kind == "byonoy.absorbance96" - && !station.runtime_executor - && !station.planner_assigns_work) - ); - } - - #[test] - fn every_default_is_complete_canonical_and_revalidates() { - for backend in KNOWN_BACKENDS { - let profile = default_target_profile(backend, "bench").unwrap(); - assert_eq!(profile.backend, backend); - assert_eq!(profile.sha256.len(), 64); - let canonical = profile.canonical_toml.parse::().unwrap(); - assert!( - canonical - .get("target") - .and_then(toml::Value::as_table) - .and_then(|target| target.get("name")) - .is_none() - ); - let round_trip = validate_target_profile("bench", &profile.canonical_toml).unwrap(); - assert_eq!(round_trip.sha256, profile.sha256); - assert_eq!(round_trip.canonical_json, profile.canonical_json); - } - } - - #[test] - fn validation_runs_backend_semantics_not_only_toml_parsing() { - let error = validate_target_profile( - "bad-flex", - r#"[target] -backend = "opentrons.flex" - -[instruments.small] -model = "p20_single_gen2" -mount = "left" -"#, - ) - .unwrap_err() - .to_string(); - assert!(error.contains("not a Flex pipette"), "{error}"); - } - - #[test] - fn schemas_reject_unknown_fields() { - let document = target_capabilities().unwrap(); - for target in document.targets { - assert_eq!( - target.schema["additionalProperties"], false, - "{}", - target.backend - ); - let metadata = &target.schema["$defs"]["TargetMetadata"]; - assert!(metadata["properties"].get("name").is_none()); - assert!( - target.schema["properties"]["target"]["default"] - .get("name") - .is_none() - ); - } - } -} diff --git a/crates/lab-compiler/src/backend/traits.rs b/crates/lab-compiler/src/backend/traits.rs index e7c3efa..2bde2a1 100644 --- a/crates/lab-compiler/src/backend/traits.rs +++ b/crates/lab-compiler/src/backend/traits.rs @@ -1,7 +1,5 @@ use crate::ArtifactBundle; -use crate::backend::BackendDescriptor; - /// Typed compilation boundary implemented by a concrete robot backend. /// /// The generic input and associated program preserve backend-specific IRs. A @@ -11,7 +9,6 @@ pub trait Backend { type Program; type Error: std::error::Error + Send + Sync + 'static; - fn descriptor(&self) -> BackendDescriptor; fn compile(&self, input: &Input) -> Result; } diff --git a/crates/lab-compiler/src/backend/typst/mod.rs b/crates/lab-compiler/src/backend/typst/mod.rs index 4e49fe7..ca22b4d 100644 --- a/crates/lab-compiler/src/backend/typst/mod.rs +++ b/crates/lab-compiler/src/backend/typst/mod.rs @@ -31,8 +31,13 @@ pub(in crate::backend) fn render(doc: &Doc) -> String { ) .unwrap(); } - if !doc.meta.target.is_empty() { - writeln!(output, " target: \"{}\",", escape_string(&doc.meta.target)).unwrap(); + if !doc.meta.adapter_profile.is_empty() { + writeln!( + output, + " adapter-profile: \"{}\",", + escape_string(&doc.meta.adapter_profile) + ) + .unwrap(); } if !doc.meta.instrument.is_empty() { writeln!( @@ -83,12 +88,6 @@ fn render_blocks(blocks: &[Block]) -> String { } output.push('\n'); } - Block::Numbered(items) => { - for item in items { - writeln!(output, "+ {}", markup(item)).unwrap(); - } - output.push('\n'); - } Block::Table { columns, rows } => { writeln!(output, "#lab-table(").unwrap(); let align = columns @@ -246,7 +245,7 @@ mod tests { let mut doc = Doc::new(DocMeta { title: "Manual protocol".into(), subtitle: "Operator manual".into(), - target: "bench-1".into(), + adapter_profile: "adapter-1".into(), instrument: "Test rig".into(), }); doc.heading(1, [text("Stage 1 — assembly")]); @@ -260,7 +259,7 @@ mod tests { assert_eq!(rendered.matches("#show: protocol-doc.with(").count(), 1); assert!(rendered.contains("#import \"lab-style.typ\"")); assert!(rendered.contains("title: \"Manual protocol\",")); - assert!(rendered.contains("target: \"bench-1\",")); + assert!(rendered.contains("adapter-profile: \"adapter-1\",")); assert!(rendered.contains("= Stage 1 — assembly")); assert!(rendered.contains("Store `p_gfp` at 4 °C.")); assert!(rendered.contains("align: (left, right,),")); diff --git a/crates/lab-compiler/src/backend/typst/templates/lab-style.typ b/crates/lab-compiler/src/backend/typst/templates/lab-style.typ index 173be06..787654e 100644 --- a/crates/lab-compiler/src/backend/typst/templates/lab-style.typ +++ b/crates/lab-compiler/src/backend/typst/templates/lab-style.typ @@ -137,7 +137,7 @@ #let protocol-doc( title: "", subtitle: "", - target: "", + adapter-profile: "", instrument: "", version: "", kicker-text: "Generated protocol document", @@ -158,7 +158,7 @@ column-gutter: 6pt, align: horizon, lab-mark(size: 8.5pt), - [Lab v#version#if target != "" [ · #raw(target)]], + [Lab v#version#if adapter-profile != "" [ · #raw(adapter-profile)]], counter(page).display("1 of 1", both: true), ) }, @@ -209,7 +209,7 @@ columns: 3, gutter: 16pt, if instrument != "" [Instrument: #text(fill: ink)[#instrument]], - if target != "" [Target: #raw(target)], + if adapter-profile != "" [Adapter profile: #raw(adapter-profile)], [Lab toolchain v#version], ) #v(5pt) diff --git a/crates/lab-compiler/src/backend/typst/templates/sample.typ b/crates/lab-compiler/src/backend/typst/templates/sample.typ index 254c451..0c2fa91 100644 --- a/crates/lab-compiler/src/backend/typst/templates/sample.typ +++ b/crates/lab-compiler/src/backend/typst/templates/sample.typ @@ -9,7 +9,7 @@ #show: protocol-doc.with( title: "Automated plasmid build", subtitle: "Operator manual for one robot session", - target: "hamilton-star", + adapter-profile: "hamilton-star", instrument: "Hamilton STAR", version: "0.0.0-sample", ) diff --git a/crates/lab-compiler/src/backend/workcell/mod.rs b/crates/lab-compiler/src/backend/workcell/mod.rs deleted file mode 100644 index 34e330a..0000000 --- a/crates/lab-compiler/src/backend/workcell/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Workcell target: one liquid handler, the instruments beside it, and a -//! human carrying labware between them. -//! -//! This module is the containment boundary for every multi-station -//! decision: which after-run work moves to an instrument station, where -//! handoffs appear, and the coordination plan that orders a wave. Machine -//! planning itself stays in each station's own backend — the workcell -//! composes planners, it does not replace them. - -mod package; -mod profile; - -/// This backend's identity. A target profile declares it, and no other -/// spelling of it exists. -pub(in crate::backend::workcell) const BACKEND: &str = "workcell"; - -pub use package::{WorkcellBuildError, WorkcellDependencyBuildBundle, compile_dependency_build}; -pub use profile::{StationDecl, StationKind, WorkcellProfile, WorkcellProfileError}; diff --git a/crates/lab-compiler/src/backend/workcell/package.rs b/crates/lab-compiler/src/backend/workcell/package.rs deleted file mode 100644 index daaa574..0000000 --- a/crates/lab-compiler/src/backend/workcell/package.rs +++ /dev/null @@ -1,540 +0,0 @@ -//! Workcell package composition: the liquid handler's own planner runs -//! unchanged, and this module owns only the split — which after-run work -//! moves to an instrument station, the handoffs that carry labware there -//! and back, and the coordination plan that sequences all of it. - -use std::collections::{BTreeMap, BTreeSet}; - -use thiserror::Error; - -use crate::runfmt::{ - THERMOCYCLE_RUN_FORMAT, ThermocycleRunDocument, WORKCELL_RUN_FORMAT, WorkcellAction, - WorkcellNode, WorkcellRunDocument, WorkcellStation, -}; -use crate::{ArtifactBundle, ArtifactError, ProtocolLairProgram}; - -use crate::backend::document::{Column, Doc, DocMeta, code, text}; -use crate::backend::hamilton::star::StarBundle; -use crate::backend::hamilton::star::plan::{ - StarBuildError, StarExecutionPlan, ThermalRequirement, plan_selected_build, -}; -use crate::backend::hamilton::star::profile::StarTargetProfile; -use crate::backend::package::{render_full_build_instructions, render_report}; -use crate::backend::typst; -use crate::backend::workcell::profile::WorkcellProfile; -use crate::planning::{BuildInventory, DependencyBuildManifest}; -use crate::planning::{DependencyGraphError, resolve_dependency_graph}; - -#[derive(Clone, Debug)] -pub struct WorkcellDependencyBuildBundle { - manifest: DependencyBuildManifest, - artifacts: ArtifactBundle, -} - -impl WorkcellDependencyBuildBundle { - pub fn manifest(&self) -> &DependencyBuildManifest { - &self.manifest - } - - pub fn manifest_json(&self) -> Result { - pretty_json(&self.manifest) - } - - pub fn artifacts(&self) -> &ArtifactBundle { - &self.artifacts - } -} - -#[derive(Debug, Error)] -pub enum WorkcellBuildError { - #[error(transparent)] - DependencyGraph(#[from] DependencyGraphError), - #[error("failed to compile generated batch for '{artifact}': {source}")] - Backend { - artifact: String, - #[source] - source: StarBuildError, - }, - #[error("failed to serialize workcell document: {0}")] - Serialization(String), - #[error(transparent)] - Artifact(#[from] ArtifactError), -} - -fn pretty_json(value: &T) -> Result { - serde_json::to_string_pretty(value) - .map(|mut text| { - text.push('\n'); - text - }) - .map_err(|error| WorkcellBuildError::Serialization(error.to_string())) -} - -/// Compile a dependency-driven build for a workcell: per wave, the liquid -/// handler's package under its station directory, one thermocycle document -/// per lifted thermal requirement, and the coordination plan that orders -/// programs, handoffs, and remaining human steps. -pub fn compile_dependency_build( - protocol: &ProtocolLairProgram, - profile: &WorkcellProfile, - star_profile: &StarTargetProfile, - inventory: &BuildInventory, -) -> Result { - let graph = crate::backend::graph::protocol_build_graph(protocol).map_err(|source| { - WorkcellBuildError::Backend { - artifact: "".into(), - source: StarBuildError::Planning(source.into()), - } - })?; - let manifest = resolve_dependency_graph(&graph, inventory)?; - let mut waves = BTreeMap::>::new(); - for node in &manifest.nodes { - if let Some(iteration) = node.generated_in_iteration { - waves - .entry(iteration) - .or_default() - .insert(node.artifact.clone()); - } - } - - let mut artifacts = ArtifactBundle::new(); - artifacts.insert_text( - "dependency_manifest.json", - "application/json", - pretty_json(&manifest)?, - )?; - artifacts.insert_text( - "dependency_report.typ", - "text/x-typst", - typst::render(&render_report( - DocMeta::new( - "Dependency report", - "Artifact graph, wave schedule, and blockers", - &profile.target.name, - "Workcell", - ), - &manifest, - )), - )?; - artifacts.insert_text(typst::STYLE_PATH, "text/x-typst", typst::STYLE)?; - - let mut instruction_batches = Vec::new(); - for (index, (iteration, selected)) in waves.into_iter().enumerate() { - let label = selected.iter().cloned().collect::>().join(", "); - let mut plan = - plan_selected_build(protocol, star_profile, Some(&selected)).map_err(|source| { - WorkcellBuildError::Backend { - artifact: label.clone(), - source: StarBuildError::Planning(source), - } - })?; - let assignment = assign_wave(&mut plan, profile); - let automation = - StarBundle::from_plan(plan).map_err(|source| WorkcellBuildError::Backend { - artifact: label.clone(), - source: StarBuildError::Emission(source), - })?; - - let directory = format!("wave-{:03}", index + 1); - let handler = profile.liquid_handler().name.clone(); - for generated in automation.artifacts().iter() { - artifacts.insert(crate::GeneratedArtifact::bytes( - format!("{directory}/stations/{handler}/{}", generated.path()), - generated.media_type(), - generated.contents().to_vec(), - )?)?; - } - for document in &assignment.thermocycle_documents { - let cycler = profile - .thermocycler() - .expect("a thermocycle document exists only when the workcell has a cycler") - .name - .clone(); - artifacts.insert_text( - format!("{directory}/stations/{cycler}/{}.odtc.json", document.id), - "application/json", - pretty_json(document)?, - )?; - } - let coordination = WorkcellRunDocument { - format: WORKCELL_RUN_FORMAT.to_string(), - stations: profile - .stations - .iter() - .map(|station| WorkcellStation { - name: station.name.clone(), - kind: station.kind.as_str().to_string(), - program_dir: format!("stations/{}", station.name), - }) - .collect(), - nodes: assignment.nodes, - }; - artifacts.insert_text( - format!("{directory}/plan.workcell.json"), - "application/json", - pretty_json(&coordination)?, - )?; - let wave_manual = render_wave_manual( - DocMeta::new( - "Workcell wave", - "Coordination plan for one wave across the cell", - &profile.target.name, - "Workcell", - ), - &coordination, - &assignment.narrative, - &handler, - ); - artifacts.insert_text( - format!("{directory}/manual_protocol.typ"), - "text/x-typst", - typst::render(&wave_manual), - )?; - artifacts.insert_text( - format!("{directory}/{}", typst::STYLE_PATH), - "text/x-typst", - typst::STYLE, - )?; - instruction_batches.push((index + 1, iteration, label, directory, wave_manual.blocks)); - } - - artifacts.insert_text( - "manual_protocol.typ", - "text/x-typst", - typst::render(&render_full_build_instructions( - DocMeta::new( - "Automated plasmid build", - "Operator instructions for the full dependency-driven build", - &profile.target.name, - "Workcell", - ), - &manifest, - Vec::new(), - &instruction_batches, - Vec::new(), - )), - )?; - - Ok(WorkcellDependencyBuildBundle { - manifest, - artifacts, - }) -} - -/// The result of assigning one wave's plan across stations. -struct WaveAssignment { - nodes: Vec, - thermocycle_documents: Vec, - /// One human-readable line per node, for the wave manual. - narrative: Vec, -} - -/// Splits a planned wave across the workcell's stations. Runs stay on the -/// liquid handler; each thermal requirement moves to the thermocycler -/// station when one exists (bracketed by handoffs) and otherwise remains -/// its operator prose. Every after-run step leaves the run documents — -/// sequencing in a workcell belongs to the coordination plan, not to any -/// one station's package. -fn assign_wave(plan: &mut StarExecutionPlan, profile: &WorkcellProfile) -> WaveAssignment { - let handler = profile.liquid_handler().name.clone(); - let cycler = profile.thermocycler().map(|station| station.name.clone()); - let mut nodes: Vec = Vec::new(); - let mut narrative = Vec::new(); - let mut thermocycle_documents = Vec::new(); - let mut previous: Option = None; - - let mut push = |node: WorkcellNode, line: String, previous: &mut Option| { - *previous = Some(node.id.clone()); - narrative.push(line); - nodes.push(node); - }; - - for run in &mut plan.runs { - push( - WorkcellNode { - id: run.id.clone(), - after: previous.iter().cloned().collect(), - action: WorkcellAction::StationProgram { - station: handler.clone(), - document: format!("stations/{handler}/{}.star.json", run.id), - }, - }, - format!("[{handler}] {}: run {}.star.json", run.title, run.id), - &mut previous, - ); - - let lifted: BTreeMap = match &cycler { - Some(_) => run - .thermal_after - .iter() - .map(|requirement| (requirement.fallback_index, requirement.clone())) - .collect(), - None => BTreeMap::new(), - }; - - for (index, manual) in run.manual_after.iter().enumerate() { - if let (Some(cycler_name), Some(requirement)) = (&cycler, lifted.get(&index)) { - push( - WorkcellNode { - id: format!("{}.to-{cycler_name}", requirement.id), - after: previous.iter().cloned().collect(), - action: WorkcellAction::Handoff { - from: handler.clone(), - to: cycler_name.clone(), - labware: requirement.plate.clone(), - instructions: format!( - "Seal the {} and move it from {handler} to {cycler_name}; close the door.", - requirement.plate - ), - }, - }, - format!( - "[handoff] seal the {} and carry it to {cycler_name}", - requirement.plate - ), - &mut previous, - ); - push( - WorkcellNode { - id: requirement.id.clone(), - after: previous.iter().cloned().collect(), - action: WorkcellAction::StationProgram { - station: cycler_name.clone(), - document: format!( - "stations/{cycler_name}/{}.odtc.json", - requirement.id - ), - }, - }, - format!( - "[{cycler_name}] {}: run {}.odtc.json", - requirement.title, requirement.id - ), - &mut previous, - ); - push( - WorkcellNode { - id: format!("{}.return", requirement.id), - after: previous.iter().cloned().collect(), - action: WorkcellAction::Handoff { - from: cycler_name.clone(), - to: handler.clone(), - labware: requirement.plate.clone(), - instructions: format!( - "Retrieve the {} from {cycler_name} and return it to the {handler} deck position it came from.", - requirement.plate - ), - }, - }, - format!( - "[handoff] return the {} to the {handler} deck", - requirement.plate - ), - &mut previous, - ); - thermocycle_documents.push(ThermocycleRunDocument { - format: THERMOCYCLE_RUN_FORMAT.to_string(), - id: requirement.id.clone(), - title: requirement.title.clone(), - plate: requirement.plate.clone(), - profile: requirement.profile.clone(), - final_hold_celsius: requirement.final_hold_celsius, - fill_volume_ul: requirement.fill_volume_ul, - }); - } else { - push( - WorkcellNode { - id: format!("{}.manual-{}", run.id, index + 1), - after: previous.iter().cloned().collect(), - action: WorkcellAction::Manual { - title: manual.title.clone(), - instructions: manual.instructions.clone(), - }, - }, - format!("[by hand] {}", manual.title), - &mut previous, - ); - } - } - run.manual_after.clear(); - run.thermal_after.clear(); - } - - WaveAssignment { - nodes, - thermocycle_documents, - narrative, - } -} - -/// The wave's human-readable coordination narrative. Deck loading and -/// source fills stay in the liquid handler's own manual under its station -/// directory; this document owns only the order of work. -fn render_wave_manual( - meta: DocMeta, - coordination: &WorkcellRunDocument, - narrative: &[String], - handler: &str, -) -> Doc { - let mut doc = Doc::new(meta); - doc.para([ - text(format!("Load the {handler} deck and sources first: see ")), - code(format!("stations/{handler}/manual_protocol.pdf")), - text("."), - ]); - doc.heading(1, [text("Running this wave")]); - doc.para([ - text("This wave is coordinated by "), - code("plan.workcell.json"), - text(", generated by "), - code("lab build"), - text(" together with every station program in "), - code("stations/"), - text(". Start it with "), - code("lab run "), - text(": the runner validates the plan, prints the full step table, asks for confirmation, then walks the sequence below node by node, dispatching each station program to its instrument and prompting the operator for every handoff and manual step before the next node starts. "), - code("lab run --dry-run"), - text(" prints the table without touching hardware."), - ]); - doc.para_text( - "Handoffs are the only points where labware moves between stations, and they always come with explicit instructions: seal, carry, or return exactly as prompted, and confirm only after the physical move is done.", - ); - doc.heading(1, [text("Stations")]); - doc.table( - [ - Column::left("Station"), - Column::left("Kind"), - Column::left("Programs"), - ], - coordination.stations.iter().map(|station| { - vec![ - vec![text(station.name.as_str())], - vec![text(station.kind.as_str())], - vec![code(format!("{}/", station.program_dir))], - ] - }), - ); - doc.heading(1, [text("Sequence")]); - doc.numbered(narrative.iter().map(|line| vec![text(line.as_str())])); - doc.para([ - text( - "Every handoff and manual step is confirmed by the operator before the next node starts; ", - ), - code("lab run"), - text(" walks this same sequence."), - ]); - doc -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::backend::hamilton::star::plan::plan_build; - use crate::backend::hamilton::star::profile::StarTargetProfile; - use crate::test_support::golden_gate_protocol; - - const CELL: &str = r#" -[target] -backend = "workcell" - -[[station]] -name = "star-1" -kind = "hamilton.star" -profile = "hamilton-star" - -[[station]] -name = "odtc-1" -kind = "inheco.odtc" -"#; - - const CELL_WITHOUT_CYCLER: &str = r#" -[target] -backend = "workcell" - -[[station]] -name = "star-1" -kind = "hamilton.star" -profile = "hamilton-star" -"#; - - fn planned() -> StarExecutionPlan { - let protocol = golden_gate_protocol(); - let profile = - StarTargetProfile::parse("hamilton-star", "").expect("the reference bench parses"); - plan_build(&protocol, &profile).expect("the example plans") - } - - #[test] - fn a_cycler_station_lifts_every_thermal_step_and_its_prose() { - let mut plan = planned(); - let profile = WorkcellProfile::parse("cell", CELL).expect("the workcell parses"); - let assignment = assign_wave(&mut plan, &profile); - - assert_eq!( - assignment.thermocycle_documents.len(), - 3, - "assembly cycling, heat shock, and recovery all move to the cycler" - ); - assert!( - plan.runs.iter().all(|run| run.manual_after.is_empty()), - "sequencing leaves the run documents entirely in a workcell build" - ); - let ids: Vec<&str> = assignment - .nodes - .iter() - .map(|node| node.id.as_str()) - .collect(); - let position = |id: &str| { - ids.iter() - .position(|candidate| *candidate == id) - .unwrap_or_else(|| panic!("node '{id}' is in the plan: {ids:?}")) - }; - assert_eq!(position("assembly_run"), 0, "the wave opens on the handler"); - assert!( - position("assembly_thermocycle.to-odtc-1") < position("assembly_thermocycle"), - "the plate is handed to the cycler before its program runs" - ); - assert!( - position("assembly_thermocycle.return") < position("assembly_run.manual-2"), - "the plate returns before the operator stages the next stage" - ); - assert!( - position("transformation_heat_shock") < position("transformation_recovery_run"), - "the heat shock finishes before the recovery run's liquid handling" - ); - let program_documents: Vec<&str> = assignment - .nodes - .iter() - .filter_map(|node| match &node.action { - WorkcellAction::StationProgram { document, .. } => Some(document.as_str()), - _ => None, - }) - .collect(); - assert!( - program_documents.contains(&"stations/odtc-1/assembly_thermocycle.odtc.json"), - "cycler programs live under the cycler's station directory: {program_documents:?}" - ); - } - - #[test] - fn without_a_cycler_every_after_step_stays_operator_prose() { - let mut plan = planned(); - let manual_steps: usize = plan.runs.iter().map(|run| run.manual_after.len()).sum(); - let profile = - WorkcellProfile::parse("cell", CELL_WITHOUT_CYCLER).expect("the workcell parses"); - let assignment = assign_wave(&mut plan, &profile); - assert!( - assignment.thermocycle_documents.is_empty(), - "nothing lifts without a station to receive it" - ); - let manual_nodes = assignment - .nodes - .iter() - .filter(|node| matches!(node.action, WorkcellAction::Manual { .. })) - .count(); - assert_eq!( - manual_nodes, manual_steps, - "each manual step becomes one coordination node" - ); - } -} diff --git a/crates/lab-compiler/src/backend/workcell/profile.rs b/crates/lab-compiler/src/backend/workcell/profile.rs deleted file mode 100644 index 7fd5b44..0000000 --- a/crates/lab-compiler/src/backend/workcell/profile.rs +++ /dev/null @@ -1,337 +0,0 @@ -//! Deserializable shape of a workcell target profile: the stations a bench -//! composes and the transport between them. - -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -use crate::backend::workcell::BACKEND; - -/// A workcell: one liquid handler, the instruments beside it, and a human -/// carrying labware between them. Station machine configuration is not -/// repeated here — a robot station names an existing single-machine target -/// profile, and instrument stations carry only bench properties. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct WorkcellProfile { - #[serde(default)] - pub target: TargetMetadata, - #[serde(rename = "station")] - pub stations: Vec, - #[serde(default)] - pub transport: Transport, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct TargetMetadata { - /// The workcell this profile describes, supplied by the loader from the - /// profile's filename. - #[serde(skip_deserializing, default)] - pub name: String, - #[serde(default = "default_backend")] - pub backend: String, -} - -impl Default for TargetMetadata { - fn default() -> Self { - Self { - name: String::new(), - backend: default_backend(), - } - } -} - -fn default_backend() -> String { - BACKEND.to_string() -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct StationDecl { - pub name: String, - pub kind: StationKind, - /// For a robot station: the single-machine target profile it runs, - /// named the way targets are (`targets/.toml`). - #[serde(default)] - pub profile: Option, - /// For a networked instrument: where it answers on this bench. A bench - /// property only — compiled artifacts never depend on it. - #[serde(default)] - pub address: Option, -} - -/// The station kinds this toolchain can plan for. The kind fixes the -/// station's capabilities; assignment is deterministic over kinds rather -/// than negotiated. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub enum StationKind { - #[serde(rename = "hamilton.star")] - HamiltonStar, - #[serde(rename = "inheco.odtc")] - InhecoOdtc, - #[serde(rename = "byonoy.absorbance96")] - ByonoyAbsorbance96, -} - -impl StationKind { - pub fn as_str(&self) -> &'static str { - match self { - Self::HamiltonStar => "hamilton.star", - Self::InhecoOdtc => "inheco.odtc", - Self::ByonoyAbsorbance96 => "byonoy.absorbance96", - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Transport { - #[serde(default = "default_transport")] - pub between: String, -} - -impl Default for Transport { - fn default() -> Self { - Self { - between: default_transport(), - } - } -} - -fn default_transport() -> String { - "human".to_string() -} - -#[derive(Debug, Error, PartialEq, Eq)] -pub enum WorkcellProfileError { - #[error("failed to parse workcell profile: {0}")] - Toml(String), - #[error("target profile declares backend '{found}', but this backend is '{expected}'")] - WrongBackend { - expected: &'static str, - found: String, - }, - #[error("a workcell declares at least one station")] - NoStations, - #[error("station name '{name}' is declared twice; station names identify handoff endpoints")] - DuplicateStation { name: String }, - #[error( - "a workcell needs exactly one liquid-handler station (kind 'hamilton.star'), found {found}" - )] - LiquidHandlerCount { found: usize }, - #[error( - "station '{name}' is the liquid handler but names no profile; add profile = \"\" pointing at its single-machine target" - )] - MissingStationProfile { name: String }, - #[error("a workcell declares at most one '{kind}' station, found {found}")] - DuplicateInstrument { kind: &'static str, found: usize }, - #[error( - "transport between stations is '{found}'; a human carrying labware is the only transport this toolchain plans for" - )] - UnsupportedTransport { found: String }, -} - -impl WorkcellProfile { - /// Parses and validates a profile. The name comes from the loader — a - /// profile is selected as `targets/.toml`, so the file cannot - /// disagree with its own name. - pub fn parse(name: &str, text: &str) -> Result { - let mut profile: Self = - toml::from_str(text).map_err(|error| WorkcellProfileError::Toml(error.to_string()))?; - profile.target.name = name.to_owned(); - profile.validate()?; - Ok(profile) - } - - fn validate(&self) -> Result<(), WorkcellProfileError> { - if self.target.backend != BACKEND { - return Err(WorkcellProfileError::WrongBackend { - expected: BACKEND, - found: self.target.backend.clone(), - }); - } - if self.stations.is_empty() { - return Err(WorkcellProfileError::NoStations); - } - let mut seen = std::collections::BTreeSet::new(); - for station in &self.stations { - if !seen.insert(station.name.as_str()) { - return Err(WorkcellProfileError::DuplicateStation { - name: station.name.clone(), - }); - } - } - let handlers: Vec<&StationDecl> = self - .stations - .iter() - .filter(|station| station.kind == StationKind::HamiltonStar) - .collect(); - if handlers.len() != 1 { - return Err(WorkcellProfileError::LiquidHandlerCount { - found: handlers.len(), - }); - } - if handlers[0].profile.is_none() { - return Err(WorkcellProfileError::MissingStationProfile { - name: handlers[0].name.clone(), - }); - } - for kind in [StationKind::InhecoOdtc, StationKind::ByonoyAbsorbance96] { - let count = self - .stations - .iter() - .filter(|station| station.kind == kind) - .count(); - if count > 1 { - return Err(WorkcellProfileError::DuplicateInstrument { - kind: kind.as_str(), - found: count, - }); - } - } - if self.transport.between != "human" { - return Err(WorkcellProfileError::UnsupportedTransport { - found: self.transport.between.clone(), - }); - } - Ok(()) - } - - /// The single liquid-handler station. - pub fn liquid_handler(&self) -> &StationDecl { - self.stations - .iter() - .find(|station| station.kind == StationKind::HamiltonStar) - .expect("validation guaranteed exactly one liquid handler") - } - - /// The thermocycler station, when the workcell has one. - pub fn thermocycler(&self) -> Option<&StationDecl> { - self.stations - .iter() - .find(|station| station.kind == StationKind::InhecoOdtc) - } - - /// The plate-reader station, when the workcell has one. - pub fn reader(&self) -> Option<&StationDecl> { - self.stations - .iter() - .find(|station| station.kind == StationKind::ByonoyAbsorbance96) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - const FULL: &str = r#" -[target] -backend = "workcell" - -[[station]] -name = "star-1" -kind = "hamilton.star" -profile = "hamilton-star" - -[[station]] -name = "odtc-1" -kind = "inheco.odtc" -address = "192.168.1.40:8080" - -[[station]] -name = "reader-1" -kind = "byonoy.absorbance96" - -[transport] -between = "human" -"#; - - #[test] - fn a_full_workcell_parses_and_names_its_stations() { - let profile = WorkcellProfile::parse("bench-cell", FULL).expect("the profile is valid"); - assert_eq!(profile.target.name, "bench-cell"); - assert_eq!(profile.liquid_handler().name, "star-1"); - assert_eq!( - profile.liquid_handler().profile.as_deref(), - Some("hamilton-star") - ); - assert_eq!( - profile.thermocycler().map(|s| s.name.as_str()), - Some("odtc-1") - ); - assert_eq!(profile.reader().map(|s| s.name.as_str()), Some("reader-1")); - } - - #[test] - fn rejects_a_profile_written_for_another_backend() { - let error = WorkcellProfile::parse("cell", "[target]\nbackend = \"hamilton.star\"\n") - .expect_err("this backend compiles only its own profiles"); - assert!(error.to_string().contains(BACKEND), "{error}"); - } - - #[test] - fn rejects_a_workcell_without_exactly_one_liquid_handler() { - let error = WorkcellProfile::parse( - "cell", - "[[station]]\nname = \"odtc-1\"\nkind = \"inheco.odtc\"\n", - ) - .expect_err("an instrument alone is not a workcell"); - assert_eq!(error, WorkcellProfileError::LiquidHandlerCount { found: 0 }); - } - - #[test] - fn rejects_a_liquid_handler_without_a_profile_reference() { - let error = WorkcellProfile::parse( - "cell", - "[[station]]\nname = \"star-1\"\nkind = \"hamilton.star\"\n", - ) - .expect_err("the liquid handler must name its machine profile"); - assert_eq!( - error, - WorkcellProfileError::MissingStationProfile { - name: "star-1".into() - } - ); - } - - #[test] - fn rejects_duplicate_station_names_and_duplicate_instruments() { - let twice = r#" -[[station]] -name = "star-1" -kind = "hamilton.star" -profile = "hamilton-star" - -[[station]] -name = "star-1" -kind = "inheco.odtc" -"#; - assert_eq!( - WorkcellProfile::parse("cell", twice).expect_err("names collide"), - WorkcellProfileError::DuplicateStation { - name: "star-1".into() - } - ); - } - - #[test] - fn rejects_transport_this_toolchain_cannot_plan() { - let armed = r#" -[[station]] -name = "star-1" -kind = "hamilton.star" -profile = "hamilton-star" - -[transport] -between = "arm" -"#; - let error = WorkcellProfile::parse("cell", armed).expect_err("arms are not planned yet"); - assert_eq!( - error, - WorkcellProfileError::UnsupportedTransport { - found: "arm".into() - } - ); - } -} diff --git a/crates/lab-compiler/src/bin/lab-opt/main.rs b/crates/lab-compiler/src/bin/lab-opt/main.rs index e74e051..92eff70 100644 --- a/crates/lab-compiler/src/bin/lab-opt/main.rs +++ b/crates/lab-compiler/src/bin/lab-opt/main.rs @@ -41,7 +41,7 @@ struct Cli { #[arg(long)] ir_printing_dir: Option, - /// Print the registered target-independent passes and exit. + /// Print the registered facility-independent passes and exit. #[arg(long)] list_passes: bool, } diff --git a/crates/lab-compiler/src/bin/labc/main.rs b/crates/lab-compiler/src/bin/labc/main.rs index 4ecd421..042e10f 100644 --- a/crates/lab-compiler/src/bin/labc/main.rs +++ b/crates/lab-compiler/src/bin/labc/main.rs @@ -4,9 +4,9 @@ use anyhow::{Context, Result, bail}; use clap::{Parser, ValueEnum}; use lab_compiler::ProtocolLairProgram; use lab_compiler::backend::Backend; -use lab_compiler::backend::opentrons::flex::{FlexBackend, FlexTargetProfile}; -use lab_compiler::backend::opentrons::ot2::{Ot2Backend, Ot2TargetProfile}; -use lab_compiler::planning::BuildInventory; +use lab_compiler::backend::opentrons::flex::{FlexAdapterProfile, FlexBackend}; +use lab_compiler::backend::opentrons::ot2::{Ot2AdapterProfile, Ot2Backend}; +use lab_compiler::planning::{BuildInventory, LegacyBuildInventory}; use lab_compiler::{PortableLairProgram, compile_module, parse_module, render_checked_module}; #[derive(Debug, Parser)] @@ -27,9 +27,12 @@ struct Cli { /// JSON inventory used by dependency-plan and full-build-bundle. #[arg(long)] inventory: Option, - /// TOML target profile describing the bench to compile for. + /// Explicit adapter implementation used by low-level backend emission. + #[arg(long, default_value = "opentrons.ot2")] + adapter: String, + /// TOML operational profile for the explicitly selected adapter. #[arg(long)] - target_profile: Option, + adapter_profile: Option, } #[derive(Clone, Copy, Debug, ValueEnum)] @@ -47,30 +50,21 @@ enum Emit { FullBuildBundle, } -/// A target profile parsed for whichever backend it declares. The `backend` -/// key is peeked out of the TOML before committing to a profile schema; an -/// absent key means `opentrons.ot2`, matching that profile schema's default. -enum TargetProfile { - Ot2(Ot2TargetProfile), - Flex(FlexTargetProfile), +enum AdapterProfile { + Ot2(Ot2AdapterProfile), + Flex(FlexAdapterProfile), } -fn parse_target_profile(name: &str, contents: &str) -> Result { - let table = contents - .parse::() - .context("failed to parse target profile")?; - let backend = table - .get("target") - .and_then(|target| target.get("backend")) - .and_then(|backend| backend.as_str()) - .unwrap_or("opentrons.ot2"); - match backend { - "opentrons.ot2" => Ok(TargetProfile::Ot2(Ot2TargetProfile::parse(name, contents)?)), - "opentrons.flex" => Ok(TargetProfile::Flex(FlexTargetProfile::parse( +fn parse_adapter_profile(driver: &str, name: &str, contents: &str) -> Result { + match driver { + "opentrons.ot2" => Ok(AdapterProfile::Ot2(Ot2AdapterProfile::parse( + name, contents, + )?)), + "opentrons.flex" => Ok(AdapterProfile::Flex(FlexAdapterProfile::parse( name, contents, )?)), other => bail!( - "target profile declares backend '{other}', which this toolchain does not provide; known backends are 'opentrons.ot2' and 'opentrons.flex'" + "adapter '{other}' does not support this low-level emitter; known adapters are 'opentrons.ot2' and 'opentrons.flex'" ), } } @@ -106,14 +100,15 @@ fn load_inventory(cli: &Cli) -> Result { if let Some(path) = &cli.inventory { let contents = std::fs::read_to_string(path) .with_context(|| format!("failed to read inventory {}", path.display()))?; - serde_json::from_str::(&contents) - .with_context(|| format!("failed to parse inventory {}", path.display())) + serde_json::from_str::(&contents) + .map(BuildInventory::LegacySymbols) + .with_context(|| format!("failed to parse legacy inventory {}", path.display())) } else { Ok(BuildInventory::default()) } } -fn emit_ot2(cli: &Cli, protocol: &ProtocolLairProgram, profile: Ot2TargetProfile) -> Result<()> { +fn emit_ot2(cli: &Cli, protocol: &ProtocolLairProgram, profile: Ot2AdapterProfile) -> Result<()> { use lab_compiler::backend::opentrons::ot2::{compile_dependency_build, emit_program}; if matches!(cli.emit, Emit::DependencyPlan | Emit::FullBuildBundle) { @@ -170,7 +165,7 @@ fn emit_ot2(cli: &Cli, protocol: &ProtocolLairProgram, profile: Ot2TargetProfile Ok(()) } -fn emit_flex(cli: &Cli, protocol: &ProtocolLairProgram, profile: FlexTargetProfile) -> Result<()> { +fn emit_flex(cli: &Cli, protocol: &ProtocolLairProgram, profile: FlexAdapterProfile) -> Result<()> { use lab_compiler::backend::opentrons::flex::{compile_dependency_build, emit_program}; if matches!(cli.emit, Emit::DependencyPlan | Emit::FullBuildBundle) { @@ -260,23 +255,21 @@ fn main() -> Result<()> { cli.source.display() ) })?; - let profile = match &cli.target_profile { + let profile = match &cli.adapter_profile { Some(path) => { let contents = std::fs::read_to_string(path) - .with_context(|| format!("failed to read target profile {}", path.display()))?; - // A profile is named by its file, the same way `lab build` - // resolves one under `targets/`. + .with_context(|| format!("failed to read adapter profile {}", path.display()))?; let name = path .file_stem() .and_then(|stem| stem.to_str()) - .with_context(|| format!("target profile {} has no name", path.display()))?; - parse_target_profile(name, &contents) - .with_context(|| format!("failed to load target profile {}", path.display()))? + .with_context(|| format!("adapter profile {} has no name", path.display()))?; + parse_adapter_profile(&cli.adapter, name, &contents) + .with_context(|| format!("failed to load adapter profile {}", path.display()))? } - None => TargetProfile::Ot2(Ot2TargetProfile::default()), + None => parse_adapter_profile(&cli.adapter, &cli.adapter, "")?, }; match profile { - TargetProfile::Ot2(profile) => emit_ot2(&cli, &protocol, profile), - TargetProfile::Flex(profile) => emit_flex(&cli, &protocol, profile), + AdapterProfile::Ot2(profile) => emit_ot2(&cli, &protocol, profile), + AdapterProfile::Flex(profile) => emit_flex(&cli, &protocol, profile), } } diff --git a/crates/lab-compiler/src/lair/dialect/README.md b/crates/lab-compiler/src/lair/dialect/README.md index 05cf5dd..4a607ad 100644 --- a/crates/lab-compiler/src/lair/dialect/README.md +++ b/crates/lab-compiler/src/lair/dialect/README.md @@ -11,12 +11,12 @@ Pliron is an implementation detail of this layer. Pliron contexts, modules, valu The first vertical slice contains: - the `design` dialect for declarative plasmid artifact values; -- the `workflow` dialect for target-neutral realization, provision, transformation, recovery, dilution, and plating intent with typed material use-def edges; -- the `protocol` dialect for target-selected provision, synthesis, assembly, transformation, recovery, dilution, plating, selection, screening, growth, purification, sampling, sequencing, quantification, and acceptance; +- the `workflow` dialect for method-neutral realization, provision, transformation, recovery, dilution, and plating intent with typed material use-def edges; +- the `protocol` dialect for method-selected provision, synthesis, assembly, transformation, recovery, dilution, plating, selection, screening, growth, purification, sampling, sequencing, quantification, and acceptance; - Protocol material-state types such as `CircularDna`, `ColonyPool`, `CloneCulture`, and `PurifiedPlasmid`; - Protocol evidence types for sequence identity, concentration, and volume. -The dialects are layers within LAIR; no individual dialect is itself “the LAIR dialect.” Design and Workflow form the portable source-lowering boundary. A dialect conversion selects Protocol operations and eliminates Workflow operations before a robot backend runs. Planned lower layers add resource binding and scheduling and execution-target operations. +The dialects are layers within LAIR; no individual dialect is itself “the LAIR dialect.” Design and Workflow form the portable source-lowering boundary. A dialect conversion selects Protocol operations and eliminates Workflow operations while remaining independent of any facility. Planning then binds requirements to capability offerings and assets before an adapter lowers the bound protocol to device operations. ## Physical-resource rule diff --git a/crates/lab-compiler/src/lair/dialect/design/mod.rs b/crates/lab-compiler/src/lair/dialect/design/mod.rs index b26fadc..644cbb2 100644 --- a/crates/lab-compiler/src/lair/dialect/design/mod.rs +++ b/crates/lab-compiler/src/lair/dialect/design/mod.rs @@ -126,7 +126,7 @@ impl Verify for DesignDnaSequenceOp { operands = (sequence: DnaSequenceType), results = (design: DesignType) )] -/// Declare a target-neutral circular plasmid design and its acceptance intent. +/// Declare a facility-independent circular plasmid design and its acceptance intent. pub struct DesignPlasmidOp; impl DesignPlasmidOp { @@ -240,7 +240,7 @@ impl Verify for DesignPlasmidOp { interfaces = [NOpdsInterface<0>], results = (design: DesignType) )] -/// Declare a target-neutral engineered organism: a chassis and the plasmid +/// Declare a facility-independent engineered organism: a chassis and the plasmid /// designs it carries. A strain has no sequence of its own; its identity is the /// pairing of a host with a defined set of designs. pub struct DesignStrainOp; diff --git a/crates/lab-compiler/src/lair/dialect/protocol/README.md b/crates/lab-compiler/src/lair/dialect/protocol/README.md index bfbe0e2..d0a1171 100644 --- a/crates/lab-compiler/src/lair/dialect/protocol/README.md +++ b/crates/lab-compiler/src/lair/dialect/protocol/README.md @@ -1,6 +1,6 @@ # Protocol dialect -The `protocol` dialect is LAIR's target-selected biological-procedure layer. Its operations describe what must happen to materials and what evidence must be produced, without prematurely embedding containers, inventory lots, schedules, locations, or instrument instructions. +The `protocol` dialect is LAIR's method-selected biological-procedure layer. Its operations describe what must happen to materials and what evidence must be produced, without prematurely embedding containers, inventory lots, schedules, locations, or instrument instructions. The initial dialect contains operations for provision, synthesis, assembly, transformation, recovery, dilution, plating, selection, screening, growth, purification, sampling, sequencing, quantification, and acceptance. Its material-state types include `CircularDna`, `RecoveredCulture`, `DilutedCulture`, `SelectionPlate`, `ColonyPool`, `CloneCulture`, and `PurifiedPlasmid`; its evidence types represent sequence identity, concentration, and volume. @@ -8,4 +8,4 @@ Protocol material values are affine: they may have at most one consumer. Branchi ## Current boundary -This dialect represents target-selected biological procedures, not robot instructions. Source workflow structure lives in the preceding Workflow dialect; protocol selection retains only the material dataflow and policy required by the selected procedure. Inventory lots, containers, locations, schedules, device resources, and robot commands belong to later lowering and runtime layers rather than fields collapsed into Protocol operations. +This dialect represents method-selected biological procedures, not robot instructions. Source workflow structure lives in the preceding Workflow dialect; protocol selection retains only the material dataflow and policy required by the selected procedure. Inventory lots, containers, locations, schedules, device resources, and robot commands belong to facility allocation, adapter lowering, and runtime layers rather than fields collapsed into Protocol operations. diff --git a/crates/lab-compiler/src/lair/dialect/protocol/mod.rs b/crates/lab-compiler/src/lair/dialect/protocol/mod.rs index c2b4ddf..53aba7a 100644 --- a/crates/lab-compiler/src/lair/dialect/protocol/mod.rs +++ b/crates/lab-compiler/src/lair/dialect/protocol/mod.rs @@ -1,4 +1,4 @@ -//! LAIR Protocol dialect for target-selected biological procedures and evidence. +//! LAIR Protocol dialect for method-selected biological procedures and evidence. use pliron::context::Context; use pliron::derive::{pliron_attr, pliron_type}; @@ -8,7 +8,7 @@ mod manufacturing; mod validation; mod verification; -/// A DNA assembly strategy selected for the target laboratory. +/// A DNA assembly strategy selected independently of any facility. #[pliron_attr(name = "protocol.assembly_method", format, verifier = "succ")] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub(crate) enum AssemblyMethodAttr { diff --git a/crates/lab-compiler/src/lair/dialect/workflow/README.md b/crates/lab-compiler/src/lair/dialect/workflow/README.md index b74d476..4906cb3 100644 --- a/crates/lab-compiler/src/lair/dialect/workflow/README.md +++ b/crates/lab-compiler/src/lair/dialect/workflow/README.md @@ -1,7 +1,7 @@ # Workflow dialect -The `workflow` dialect is LAIR's target-neutral procedure-intent layer. It is emitted from checked Lab effects and preserves the source program's operation order through typed material use-def edges. +The `workflow` dialect is LAIR's method-neutral procedure-intent layer. It is emitted from checked Lab effects and preserves the source program's operation order through typed material use-def edges. The current plasmid-build slice contains `workflow.realize`, `workflow.provision`, `workflow.transform`, `workflow.recover`, `workflow.dilute`, and `workflow.plate`. Build policy is owned by the operation to which it applies: assembly inputs and replicates are on `realize`, transformation replicates are on `transform`, serial-dilution count is on `dilute`, and plating selection and replicates are on `plate`. Cross-workflow artifact dependencies remain explicit identities on `realize` because workflow parameters represent materials supplied by another workflow invocation rather than SSA values produced in the same module body. -Workflow operations do not select a laboratory method, inventory lot, container, schedule, deck position, instrument, or robot command. A profile-selected dialect conversion must replace every Workflow operation and material value with Protocol LAIR before the target-selected Protocol stage contract can pass. +Workflow operations do not select a laboratory method, inventory lot, container, schedule, deck position, instrument, or robot command. A method-selection dialect conversion must replace every Workflow operation and material value with Protocol LAIR before the method-selected Protocol stage contract can pass. diff --git a/crates/lab-compiler/src/lair/pipeline.rs b/crates/lab-compiler/src/lair/pipeline.rs index 6f1b23e..d70e0d9 100644 --- a/crates/lab-compiler/src/lair/pipeline.rs +++ b/crates/lab-compiler/src/lair/pipeline.rs @@ -49,13 +49,13 @@ pub struct PassInfo { const MATERIAL_LINEARITY: PassInfo = PassInfo { name: "protocol-check-material-linearity", summary: "require every physical material value to have at most one consumer", - input: IrStage::TargetSelectedProtocol, - output: IrStage::TargetSelectedProtocol, + input: IrStage::MethodSelectedProtocol, + output: IrStage::MethodSelectedProtocol, }; const REGISTERED_PASSES: [PassInfo; 1] = [MATERIAL_LINEARITY]; -/// Return the target-independent passes available to textual IR tooling. +/// Return the facility-independent passes available to textual IR tooling. pub fn registered_passes() -> &'static [PassInfo] { ®ISTERED_PASSES } diff --git a/crates/lab-compiler/src/lair/program.rs b/crates/lab-compiler/src/lair/program.rs index 9ed9225..8e10a94 100644 --- a/crates/lab-compiler/src/lair/program.rs +++ b/crates/lab-compiler/src/lair/program.rs @@ -41,7 +41,7 @@ pub enum ProtocolLairError { Verification(String), #[error("generated Protocol LAIR failed material-linearity analysis: {0}")] MaterialLinearity(String), - #[error("generated LAIR does not satisfy the target-selected Protocol contract: {0}")] + #[error("generated LAIR does not satisfy the method-selected Protocol contract: {0}")] Stage(String), } @@ -147,7 +147,7 @@ impl PortableLairProgram { self.module.get_operation().disp(&self.context).to_string() } - /// Consume target-neutral Workflow LAIR and select the supported concrete + /// Consume method-neutral Workflow LAIR and select the supported concrete /// plasmid-build Protocol. No backend planning occurs at this boundary. pub fn select_protocol(mut self) -> Result { crate::lair::protocol_selection::select_plasmid_build_protocol( @@ -167,9 +167,9 @@ impl PortableLairProgram { ProtocolLairError::MaterialLinearity(error.disp(&self.context).to_string()) })?; let stage = detect_stage(&self.context, self.module).map_err(ProtocolLairError::Stage)?; - if stage != IrStage::TargetSelectedProtocol { + if stage != IrStage::MethodSelectedProtocol { return Err(ProtocolLairError::Stage(format!( - "expected target-selected-protocol, found {stage}" + "expected method-selected-protocol, found {stage}" ))); } Ok(ProtocolLairProgram { @@ -180,7 +180,7 @@ impl PortableLairProgram { } /// Owned, verifier-valid Protocol LAIR. Robot planners consume this boundary -/// directly; it cannot be constructed from unchecked source or target IR. +/// directly; it cannot be constructed from unchecked source or device IR. pub struct ProtocolLairProgram { context: Context, module: ModuleOp, diff --git a/crates/lab-compiler/src/lair/protocol_selection.rs b/crates/lab-compiler/src/lair/protocol_selection.rs index 07c9007..9aa7df2 100644 --- a/crates/lab-compiler/src/lair/protocol_selection.rs +++ b/crates/lab-compiler/src/lair/protocol_selection.rs @@ -1,4 +1,4 @@ -//! Dialect conversion from target-neutral Workflow intent to Protocol LAIR. +//! Dialect conversion from method-neutral Workflow intent to Protocol LAIR. use pliron::attribute::AttrObj; use pliron::builtin::attributes::{StringAttr, VecAttr}; diff --git a/crates/lab-compiler/src/lair/session.rs b/crates/lab-compiler/src/lair/session.rs index f33c3be..793db39 100644 --- a/crates/lab-compiler/src/lair/session.rs +++ b/crates/lab-compiler/src/lair/session.rs @@ -144,7 +144,7 @@ impl CompilerSession { .map_err(SessionError::StageContract) } - /// Run a target-independent textual pass pipeline. + /// Run a facility-independent textual pass pipeline. pub fn run_pass_pipeline(&mut self, pipeline: &PassPipeline) -> Result<(), SessionError> { self.verify()?; for registered_pass in pipeline.passes() { diff --git a/crates/lab-compiler/src/lair/source_lowering.rs b/crates/lab-compiler/src/lair/source_lowering.rs index f406ad9..610cc3d 100644 --- a/crates/lab-compiler/src/lair/source_lowering.rs +++ b/crates/lab-compiler/src/lair/source_lowering.rs @@ -1,4 +1,4 @@ -//! Lower checked Lab modules into target-neutral Design and Workflow intent. +//! Lower checked Lab modules into facility-independent Design and Workflow intent. use std::collections::{BTreeMap, BTreeSet}; @@ -47,7 +47,7 @@ struct RealizationFlow { struct BuildLoweringContext<'a> { flows: &'a BTreeMap, - identities: &'a BTreeMap, + supplier_identities: &'a BTreeMap, stated: &'a BTreeMap>, bindings: &'a BTreeMap<(String, String), TypedExpression>, } @@ -57,8 +57,7 @@ pub enum SourceLoweringError { #[error("source module does not declare any build artifacts")] EmptyBuild, #[error( - "the opentrons-ot2 target does not know how to build a '{kind}', which artifact \ - '{artifact}' declares" + "portable LAIR lowering does not support artifact kind '{kind}' declared by '{artifact}'" )] UnsupportedArtifactKind { artifact: String, kind: String }, #[error("artifact '{artifact}' is missing workflow input '{field}'")] @@ -112,7 +111,7 @@ pub enum SourceLoweringError { /// One declared artifact together with the workflow that realizes it. The two /// kinds are separate because they name different materials and produce -/// different laboratory stages, not because a target requires it. +/// different laboratory stages, not because a device adapter requires it. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum BuildArtifactIntent { Plasmid(PlasmidArtifactIntent), @@ -172,7 +171,7 @@ pub(crate) struct AssemblyRecipeIntent { /// Golden Gate reaction chemistry. These are scientific choices stated by the /// design, not properties of the bench that runs it, so they travel with the -/// artifact rather than with a target profile. +/// artifact rather than with facility or adapter configuration. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct AssemblyChemistryIntent { pub reaction_volume_ul: u16, @@ -232,20 +231,20 @@ pub(crate) struct StrainArtifactIntent { /// intent. This is the only layer that knows the standard-library operation /// identities used by the source frontend. /// -/// The modules are one program. Inventory identities, artifact declarations, +/// The modules are one program. Supplier identities, artifact declarations, /// and realization workflows are read across all of them, so a declaration and /// the workflow that realizes it may live in different modules. The caller /// supplies them in a deterministic order. pub(crate) fn lower_build_intent( modules: &[&CheckedModule], ) -> Result, SourceLoweringError> { - let identities = inventory_identities(modules); + let supplier_identities = supplier_identities(modules); let stated = inventory_properties(modules); let bindings = binding_values(modules); - let flows = realization_flows(modules, &identities)?; + let flows = realization_flows(modules, &supplier_identities)?; let context = BuildLoweringContext { flows: &flows, - identities: &identities, + supplier_identities: &supplier_identities, stated: &stated, bindings: &bindings, }; @@ -289,7 +288,7 @@ fn lower_artifact( properties: &[lab_language::CheckedProperty], context: &BuildLoweringContext<'_>, ) -> Result { - let identities = context.identities; + let supplier_identities = context.supplier_identities; let stated = context.stated; let bindings = context.bindings; let find = |field: &'static str| { @@ -302,9 +301,10 @@ fn lower_artifact( field, }) }; - let symbol = |field, accepted| checked_symbol(name, field, find(field)?, identities, accepted); + let symbol = + |field, accepted| checked_symbol(name, field, find(field)?, supplier_identities, accepted); let symbols = - |field, accepted| checked_symbols(name, field, find(field)?, identities, accepted); + |field, accepted| checked_symbols(name, field, find(field)?, supplier_identities, accepted); let owner = |owner_field: &'static str| { properties .iter() @@ -412,8 +412,8 @@ fn lower_artifact( }, actions: flow.actions.clone(), })), - // This backend builds plasmids and strains. A package may declare other - // kinds; a target that does not know how to make one says so. + // The initial portable lowering supports plasmid and strain intents. A + // package may declare other kinds, which require their own lowering contract. other => Err(SourceLoweringError::UnsupportedArtifactKind { artifact: name.to_owned(), kind: other.to_owned(), @@ -462,14 +462,17 @@ fn inventory_properties( /// What each catalogued symbol calls the item a supplier lists. /// -/// A catalog declaration carries both, so this reads two fields rather than -/// recognizing the shape of a synthesized call. -fn inventory_identities(modules: &[&CheckedModule]) -> BTreeMap { +/// This deliberately ignores the separate SBOL Component IRI. Existing device +/// manifests require order identifiers, while inventory binding requires exact +/// biological-design identities. +fn supplier_identities(modules: &[&CheckedModule]) -> BTreeMap { declarations(modules) .filter_map(|declaration| match declaration { - CheckedDeclaration::Catalog { name, identity, .. } => { - Some((name.clone(), identity.clone())) - } + CheckedDeclaration::Catalog { + name, + supplier_identity, + .. + } => Some((name.clone(), supplier_identity.clone())), _ => None, }) .collect() diff --git a/crates/lab-compiler/src/lair/stage.rs b/crates/lab-compiler/src/lair/stage.rs index c9a1d7e..dd6482a 100644 --- a/crates/lab-compiler/src/lair/stage.rs +++ b/crates/lab-compiler/src/lair/stage.rs @@ -10,12 +10,12 @@ use pliron::operation::Operation; /// A verifier-valid boundary in the current Lab Compiler lowering pipeline. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum IrStage { - /// Target-neutral biological artifact intent expressed only in Design IR. + /// Facility-independent biological artifact intent expressed only in Design IR. Design, - /// Target-neutral artifact intent plus explicit Workflow material dataflow. + /// Facility-independent artifact intent plus explicit Workflow material dataflow. DesignWorkflow, - /// Target-selected Protocol IR plus the retained Design value it currently consumes. - TargetSelectedProtocol, + /// Method-selected Protocol IR plus the retained Design value it currently consumes. + MethodSelectedProtocol, } impl Display for IrStage { @@ -23,7 +23,7 @@ impl Display for IrStage { formatter.write_str(match self { Self::Design => "design", Self::DesignWorkflow => "design-workflow", - Self::TargetSelectedProtocol => "target-selected-protocol", + Self::MethodSelectedProtocol => "method-selected-protocol", }) } } @@ -35,9 +35,9 @@ impl FromStr for IrStage { match value { "design" => Ok(Self::Design), "design-workflow" => Ok(Self::DesignWorkflow), - "target-selected-protocol" => Ok(Self::TargetSelectedProtocol), + "method-selected-protocol" => Ok(Self::MethodSelectedProtocol), other => Err(format!( - "unknown IR stage '{other}'; expected design, design-workflow, or target-selected-protocol" + "unknown IR stage '{other}'; expected design, design-workflow, or method-selected-protocol" )), } } @@ -75,10 +75,10 @@ pub(crate) fn detect_stage(context: &Context, module: ModuleOp) -> Result Ok(IrStage::Design), (1.., 1.., 0) => Ok(IrStage::DesignWorkflow), - (1.., 0, 1..) => Ok(IrStage::TargetSelectedProtocol), + (1.., 0, 1..) => Ok(IrStage::MethodSelectedProtocol), (0, _, _) => Err("a Lab Compiler module must contain at least one design operation".into()), (_, 1.., 1..) => Err( - "Workflow operations must be fully eliminated before the target-selected Protocol boundary" + "Workflow operations must be fully eliminated before the method-selected Protocol boundary" .into(), ), } diff --git a/crates/lab-compiler/src/lib.rs b/crates/lab-compiler/src/lib.rs index 5dba0cd..e1783b8 100644 --- a/crates/lab-compiler/src/lib.rs +++ b/crates/lab-compiler/src/lib.rs @@ -10,10 +10,11 @@ mod test_support; pub use artifact::{ArtifactBundle, ArtifactError, GeneratedArtifact}; pub use lab_language::{ - Analysis, CheckedModule, Diagnostic, DiagnosticSeverity, MaterialFlowError, ModuleError, - ModuleId, ModuleInterface, ParseError, SemanticEnvironment, SemanticError, SourceId, - analyze_module, analyze_module_in_environment, compile_module, compile_module_in_environment, - manifest, parse_module, render_checked_module, render_diagnostic, standard_library_manifest, + Analysis, CheckedDeclaration, CheckedModule, Diagnostic, DiagnosticSeverity, MaterialFlowError, + ModuleError, ModuleId, ModuleInterface, ParseError, SemanticEnvironment, SemanticError, + SourceId, analyze_module, analyze_module_in_environment, compile_module, + compile_module_in_environment, manifest, parse_module, render_checked_module, + render_diagnostic, standard_library_manifest, }; pub use lair::pipeline::{PassInfo, PassPipeline, PassPipelineError, registered_passes}; pub use lair::program::{ diff --git a/crates/lab-compiler/src/planning/adapters.rs b/crates/lab-compiler/src/planning/adapters.rs new file mode 100644 index 0000000..07b21fc --- /dev/null +++ b/crates/lab-compiler/src/planning/adapters.rs @@ -0,0 +1,346 @@ +//! Exact operational bindings between configured adapters and SBOLInventory offerings. + +use std::collections::BTreeSet; +use std::path::PathBuf; + +use lab_inventory::{FacilityAssetError, FacilityScalarValue, InventorySnapshot}; +use sbol_inventory::vocabulary::Qualification; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::backend::{AdapterServices, ValidatedAdapterProfile, adapter_catalog}; + +pub const ADAPTER_BINDINGS_SCHEMA_VERSION: &str = "lab.adapter-bindings.v2"; + +#[derive(Clone, Debug)] +pub struct AdapterBindingRequest { + pub asset: String, + pub driver: String, + pub profile_path: PathBuf, + pub profile: ValidatedAdapterProfile, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AdapterBindingSnapshot { + pub schema_version: String, + pub inventory_sha256: String, + pub facility: String, + pub bindings: Vec, +} + +impl AdapterBindingSnapshot { + pub fn resolve( + inventory: &InventorySnapshot, + requests: Vec, + ) -> Result { + let catalog = adapter_catalog().map_err(|error| AdapterBindingError::Catalog { + message: error.to_string(), + })?; + let mut bindings = Vec::new(); + let mut seen = BTreeSet::new(); + for request in requests { + if request.profile.driver != request.driver { + return Err(AdapterBindingError::ProfileDriverMismatch { + asset: request.asset, + binding_driver: request.driver, + profile_driver: request.profile.driver, + }); + } + if !seen.insert((request.asset.clone(), request.driver.clone())) { + return Err(AdapterBindingError::DuplicateBinding { + asset: request.asset, + driver: request.driver, + }); + } + let descriptor = catalog + .adapters + .iter() + .find(|adapter| adapter.id == request.driver) + .ok_or_else(|| AdapterBindingError::UnknownDriver { + asset: request.asset.clone(), + driver: request.driver.clone(), + })?; + let asset = inventory.facility_asset(&request.asset)?; + let mut offerings = asset + .offerings + .iter() + .filter(|offering| { + descriptor + .capabilities + .contains(offering.capability_kind.as_str()) + && descriptor + .control_modes + .contains(offering.control_mode.iri()) + }) + .map(|offering| BoundCapabilityOffering { + offering: offering.identity.as_str().to_owned(), + capability_kind: offering.capability_kind.as_str().to_owned(), + qualification: offering.qualification.iri().to_owned(), + control_mode: offering.control_mode.iri().to_owned(), + parameters: offering + .parameters + .iter() + .map(|parameter| BoundCapabilityParameter { + parameter: parameter.identity.as_str().to_owned(), + property_kind: parameter.property_kind.as_str().to_owned(), + value: match ¶meter.value { + FacilityScalarValue::Text(value) => { + BoundCapabilityParameterValue::Text { + value: value.clone(), + } + } + FacilityScalarValue::Integer(value) => { + BoundCapabilityParameterValue::Integer { + value: value.clone(), + } + } + FacilityScalarValue::Real(value) => { + BoundCapabilityParameterValue::Real { + value: value.clone(), + } + } + FacilityScalarValue::Boolean(value) => { + BoundCapabilityParameterValue::Boolean { value: *value } + } + FacilityScalarValue::Iri(value) => { + BoundCapabilityParameterValue::Iri { + value: value.as_str().to_owned(), + } + } + }, + unit: parameter.unit.as_ref().map(|unit| unit.as_str().to_owned()), + }) + .collect(), + effectively_active: offering.effectively_active, + planning_eligible: offering.effectively_active + && descriptor.services.planning + && offering.qualification >= Qualification::Plannable, + simulation_eligible: offering.effectively_active + && descriptor.services.simulation + && offering.qualification >= Qualification::Simulatable, + execution_eligible: offering.effectively_active + && descriptor.services.runtime + && offering.qualification >= Qualification::Executable, + }) + .collect::>(); + offerings.sort_by(|left, right| left.offering.cmp(&right.offering)); + if offerings.is_empty() { + return Err(AdapterBindingError::NoCompatibleOffering { + asset: request.asset, + driver: request.driver, + adapter_capabilities: render_set(&descriptor.capabilities), + adapter_control_modes: render_set(&descriptor.control_modes), + }); + } + bindings.push(ResolvedAdapterBinding { + asset: asset.identity.as_str().to_owned(), + driver: request.driver, + profile_path: request.profile_path, + profile_sha256: request.profile.sha256, + features: descriptor.features.clone(), + accepted_run_formats: descriptor.accepted_run_formats.clone(), + emitted_run_formats: descriptor.emitted_run_formats.clone(), + services: descriptor.services.clone(), + offerings, + }); + } + bindings + .sort_by(|left, right| (&left.asset, &left.driver).cmp(&(&right.asset, &right.driver))); + Ok(Self { + schema_version: ADAPTER_BINDINGS_SCHEMA_VERSION.to_owned(), + inventory_sha256: inventory.source_sha256().to_owned(), + facility: inventory.facility().as_str().to_owned(), + bindings, + }) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ResolvedAdapterBinding { + pub asset: String, + pub driver: String, + pub profile_path: PathBuf, + pub profile_sha256: String, + pub features: BTreeSet, + pub accepted_run_formats: BTreeSet, + pub emitted_run_formats: BTreeSet, + pub services: AdapterServices, + pub offerings: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BoundCapabilityOffering { + pub offering: String, + pub capability_kind: String, + pub qualification: String, + pub control_mode: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub parameters: Vec, + pub effectively_active: bool, + pub planning_eligible: bool, + pub simulation_eligible: bool, + pub execution_eligible: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BoundCapabilityParameter { + pub parameter: String, + pub property_kind: String, + #[serde(flatten)] + pub value: BoundCapabilityParameterValue, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unit: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "value_type", rename_all = "snake_case")] +pub enum BoundCapabilityParameterValue { + Text { value: String }, + Integer { value: String }, + Real { value: String }, + Boolean { value: bool }, + Iri { value: String }, +} + +#[derive(Debug, Error)] +pub enum AdapterBindingError { + #[error("failed to load the compiler adapter catalog: {message}")] + Catalog { message: String }, + #[error(transparent)] + Asset(#[from] FacilityAssetError), + #[error("asset `{asset}` binds unknown adapter driver `{driver}`")] + UnknownDriver { asset: String, driver: String }, + #[error( + "asset `{asset}` binds driver `{binding_driver}`, but its validated profile is for `{profile_driver}`" + )] + ProfileDriverMismatch { + asset: String, + binding_driver: String, + profile_driver: String, + }, + #[error("asset `{asset}` binds adapter `{driver}` more than once")] + DuplicateBinding { asset: String, driver: String }, + #[error( + "asset `{asset}` has no offering supported by adapter `{driver}`; adapter capabilities: {adapter_capabilities}; adapter control modes: {adapter_control_modes}" + )] + NoCompatibleOffering { + asset: String, + driver: String, + adapter_capabilities: String, + adapter_control_modes: String, + }, +} + +fn render_set(values: &BTreeSet) -> String { + if values.is_empty() { + "none".to_owned() + } else { + values.iter().cloned().collect::>().join(", ") + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use crate::backend::validate_adapter_profile; + + use super::*; + + const INVENTORY: &str = r#"@prefix cap: . +@prefix ex: . +@prefix fac: . +@prefix sbol: . + +ex:facility a sbol:TopLevel, fac:Facility ; sbol:displayId "facility" ; + sbol:hasNamespace . +ex:room a sbol:TopLevel, fac:Zone ; sbol:displayId "room" ; + sbol:hasNamespace ; fac:facility ex:facility ; + fac:zoneKind fac:Room ; fac:isActive true . +ex:star a sbol:TopLevel, fac:Asset ; sbol:displayId "star" ; + sbol:hasNamespace ; fac:facility ex:facility ; + fac:assetKind fac:Instrument ; fac:locatedIn ex:room ; fac:isActive true ; + fac:capability . + + a sbol:Identified, fac:CapabilityOffering ; sbol:displayId "liquid_handling" ; + fac:capabilityKind cap:LiquidHandling ; fac:qualification fac:Plannable ; + fac:controlMode fac:ReviewedFileControl ; fac:isActive true ; + fac:parameter . + + a sbol:Identified, fac:PropertyValue ; sbol:displayId "plate_wells" ; + fac:propertyKind cap:SupportedPlateWells ; fac:integerValue 96 . +"#; + + fn inventory(contents: &str) -> (TempDir, InventorySnapshot) { + let directory = TempDir::new().unwrap(); + fs::write(directory.path().join("inventory.ttl"), contents).unwrap(); + let snapshot = InventorySnapshot::load(directory.path(), "inventory.ttl", None).unwrap(); + (directory, snapshot) + } + + fn request(driver: &str) -> AdapterBindingRequest { + AdapterBindingRequest { + asset: "https://example.org/facility/star".to_owned(), + driver: driver.to_owned(), + profile_path: PathBuf::from("adapters/star.toml"), + profile: validate_adapter_profile(driver, "star", "").unwrap(), + } + } + + #[test] + fn freezes_exact_asset_offering_and_profile_bindings_without_promoting_qualification() { + let (_directory, inventory) = inventory(INVENTORY); + + let snapshot = + AdapterBindingSnapshot::resolve(&inventory, vec![request("hamilton.star")]).unwrap(); + + assert_eq!(snapshot.schema_version, ADAPTER_BINDINGS_SCHEMA_VERSION); + assert_eq!(snapshot.facility, "https://example.org/facility/facility"); + assert_eq!(snapshot.bindings.len(), 1); + let binding = &snapshot.bindings[0]; + assert_eq!(binding.asset, "https://example.org/facility/star"); + assert_eq!(binding.driver, "hamilton.star"); + assert_eq!(binding.profile_sha256.len(), 64); + assert_eq!(binding.offerings.len(), 1); + let offering = &binding.offerings[0]; + assert_eq!( + offering.offering, + "https://example.org/facility/star/liquid_handling" + ); + assert_eq!(offering.parameters.len(), 1); + assert_eq!( + offering.parameters[0].property_kind, + "https://sbol.io/ns/capability#SupportedPlateWells" + ); + assert_eq!( + offering.parameters[0].value, + BoundCapabilityParameterValue::Integer { + value: "96".to_owned() + } + ); + assert!(offering.planning_eligible); + assert!(!offering.simulation_eligible); + assert!(!offering.execution_eligible); + + let json = serde_json::to_string(&snapshot).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + snapshot + ); + } + + #[test] + fn rejects_a_driver_without_an_exact_capability_and_control_mode_match() { + let (_directory, inventory) = inventory(INVENTORY); + + let error = + AdapterBindingSnapshot::resolve(&inventory, vec![request("inheco.odtc")]).unwrap_err(); + + assert!(matches!( + error, + AdapterBindingError::NoCompatibleOffering { .. } + )); + } +} diff --git a/crates/lab-compiler/src/planning/allocation.rs b/crates/lab-compiler/src/planning/allocation.rs new file mode 100644 index 0000000..6415812 --- /dev/null +++ b/crates/lab-compiler/src/planning/allocation.rs @@ -0,0 +1,783 @@ +//! Facility-wide allocation of reachable workflow requirements to exact SBOLInventory offerings. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +use lab_inventory::{ + FacilityAsset, FacilityAssetError, FacilityCapabilityOffering, FacilityCapabilityParameter, + FacilityScalarValue, InventorySnapshot, +}; +use lab_language::{CheckedExpression, TypedExpression}; +use sbol_inventory::vocabulary::Qualification; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::{ + ADAPTER_BINDINGS_SCHEMA_VERSION, AdapterBindingSnapshot, + CAPABILITY_REQUIREMENT_INSTANCES_SCHEMA_VERSION, CAPABILITY_REQUIREMENTS_SCHEMA_VERSION, + CapabilityParameterConstraint, CapabilityRequirement, CapabilityRequirementInstances, + CapabilityRequirements, ParameterRelation, RequirementQualification, +}; + +pub const FACILITY_ALLOCATION_SCHEMA_VERSION: &str = "lab.facility-allocation.v1"; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FacilityAllocation { + pub schema_version: String, + pub inventory_sha256: String, + pub facility: String, + pub requirements_schema_version: String, + pub instances_schema_version: String, + pub allocations: Vec, +} + +impl FacilityAllocation { + pub fn allocate( + requirements: &CapabilityRequirements, + instances: &CapabilityRequirementInstances, + inventory: &InventorySnapshot, + adapters: Option<&AdapterBindingSnapshot>, + ) -> Result { + validate_inputs(requirements, instances, inventory, adapters)?; + let templates = requirements + .requirements + .iter() + .map(|requirement| (requirement.id.as_str(), requirement)) + .collect::>(); + let assets = inventory.facility_assets()?; + let mut allocations = Vec::new(); + for instance in &instances.instances { + let requirement = templates.get(instance.template.as_str()).ok_or_else(|| { + FacilityAllocationError::MissingRequirementTemplate { + instance: instance.id.clone(), + template: instance.template.clone(), + } + })?; + allocations.push(allocate_requirement( + &instance.id, + requirement, + &assets, + adapters, + )?); + } + Ok(Self { + schema_version: FACILITY_ALLOCATION_SCHEMA_VERSION.to_owned(), + inventory_sha256: inventory.source_sha256().to_owned(), + facility: inventory.facility().as_str().to_owned(), + requirements_schema_version: requirements.schema_version.clone(), + instances_schema_version: instances.schema_version.clone(), + allocations, + }) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RequirementAllocation { + pub requirement_instance: String, + pub requirement_template: String, + pub capability_kind: String, + pub minimum_qualification: String, + pub accepted_control_modes: BTreeSet, + pub offering: String, + pub asset: String, + pub observed_qualification: String, + pub control_mode: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub parameters: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub adapter: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rejected_candidates: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct MatchedCapabilityParameter { + pub argument: String, + pub property_kind: String, + pub relation: ParameterRelation, + pub required: TypedExpression, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub required_unit: Option, + pub offering_parameter: String, + #[serde(flatten)] + pub observed: AllocationScalarValue, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_unit: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "value_type", rename_all = "snake_case")] +pub enum AllocationScalarValue { + Text { value: String }, + Integer { value: String }, + Real { value: String }, + Boolean { value: bool }, + Iri { value: String }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AllocatedAdapter { + pub driver: String, + pub profile_path: PathBuf, + pub profile_sha256: String, + pub features: BTreeSet, + pub accepted_run_formats: BTreeSet, + pub emitted_run_formats: BTreeSet, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RejectedCapabilityCandidate { + pub offering: String, + pub asset: String, + pub observed_qualification: String, + pub control_mode: String, + pub reasons: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "reason", rename_all = "snake_case")] +pub enum CandidateRejectionReason { + Inactive, + InsufficientQualification { + required: String, + observed: String, + }, + UnsupportedControlMode { + accepted: BTreeSet, + observed: String, + }, + MissingParameter { + property_kind: String, + }, + UnitMismatch { + property_kind: String, + required: Option, + observed: Option, + }, + ValueMismatch { + property_kind: String, + required: String, + observed: String, + }, + UnsupportedRequirementValue { + property_kind: String, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EligibleCapabilityCandidate { + pub offering: String, + pub asset: String, + pub observed_qualification: String, + pub control_mode: String, +} + +#[derive(Debug, Error)] +pub enum FacilityAllocationError { + #[error("{artifact} declares schema `{found}`, but allocation expects `{expected}`")] + WrongSchema { + artifact: &'static str, + expected: &'static str, + found: String, + }, + #[error( + "capability instances reference requirements schema `{instances}`, but the supplied catalog declares `{requirements}`" + )] + RequirementSchemaMismatch { + instances: String, + requirements: String, + }, + #[error( + "adapter bindings freeze inventory `{binding_hash}` facility `{binding_facility}`, but allocation uses inventory `{inventory_hash}` facility `{inventory_facility}`" + )] + AdapterInventoryMismatch { + binding_hash: String, + binding_facility: String, + inventory_hash: String, + inventory_facility: String, + }, + #[error(transparent)] + Asset(#[from] FacilityAssetError), + #[error("requirement instance `{instance}` references absent template `{template}`")] + MissingRequirementTemplate { instance: String, template: String }, + #[error( + "requirement `{requirement}` has no eligible `{capability_kind}` offering; {candidate_count} candidate(s) were rejected" + )] + NoEligibleOffering { + requirement: String, + capability_kind: String, + candidate_count: usize, + rejections: Vec, + }, + #[error( + "requirement `{requirement}` has {candidate_count} equally eligible offerings; allocation policy must select one" + )] + AmbiguousOffering { + requirement: String, + candidate_count: usize, + candidates: Vec, + }, + #[error( + "offering `{offering}` on asset `{asset}` has several planning adapters ({drivers}); select one explicitly" + )] + AmbiguousAdapter { + offering: String, + asset: String, + drivers: String, + }, +} + +fn validate_inputs( + requirements: &CapabilityRequirements, + instances: &CapabilityRequirementInstances, + inventory: &InventorySnapshot, + adapters: Option<&AdapterBindingSnapshot>, +) -> Result<(), FacilityAllocationError> { + require_schema( + "capability requirements", + &requirements.schema_version, + CAPABILITY_REQUIREMENTS_SCHEMA_VERSION, + )?; + require_schema( + "capability requirement instances", + &instances.schema_version, + CAPABILITY_REQUIREMENT_INSTANCES_SCHEMA_VERSION, + )?; + if instances.requirements_schema_version != requirements.schema_version { + return Err(FacilityAllocationError::RequirementSchemaMismatch { + instances: instances.requirements_schema_version.clone(), + requirements: requirements.schema_version.clone(), + }); + } + if let Some(adapters) = adapters { + require_schema( + "adapter bindings", + &adapters.schema_version, + ADAPTER_BINDINGS_SCHEMA_VERSION, + )?; + if adapters.inventory_sha256 != inventory.source_sha256() + || adapters.facility != inventory.facility().as_str() + { + return Err(FacilityAllocationError::AdapterInventoryMismatch { + binding_hash: adapters.inventory_sha256.clone(), + binding_facility: adapters.facility.clone(), + inventory_hash: inventory.source_sha256().to_owned(), + inventory_facility: inventory.facility().as_str().to_owned(), + }); + } + } + Ok(()) +} + +fn require_schema( + artifact: &'static str, + found: &str, + expected: &'static str, +) -> Result<(), FacilityAllocationError> { + if found == expected { + Ok(()) + } else { + Err(FacilityAllocationError::WrongSchema { + artifact, + expected, + found: found.to_owned(), + }) + } +} + +fn allocate_requirement( + instance: &str, + requirement: &CapabilityRequirement, + assets: &[FacilityAsset], + adapters: Option<&AdapterBindingSnapshot>, +) -> Result { + let minimum = inventory_qualification(requirement.minimum_qualification); + let accepted_control_modes = requirement + .accepted_control_modes + .iter() + .map(|mode| mode.iri().to_owned()) + .collect::>(); + let mut eligible = Vec::new(); + let mut rejected = Vec::new(); + for asset in assets { + for offering in &asset.offerings { + if offering.capability_kind.as_str() != requirement.capability_kind { + continue; + } + let mut reasons = Vec::new(); + if !offering.effectively_active { + reasons.push(CandidateRejectionReason::Inactive); + } + if offering.qualification < minimum { + reasons.push(CandidateRejectionReason::InsufficientQualification { + required: requirement.minimum_qualification.iri().to_owned(), + observed: offering.qualification.iri().to_owned(), + }); + } + if !accepted_control_modes.contains(offering.control_mode.iri()) { + reasons.push(CandidateRejectionReason::UnsupportedControlMode { + accepted: accepted_control_modes.clone(), + observed: offering.control_mode.iri().to_owned(), + }); + } + let mut matched = Vec::new(); + for constraint in &requirement.parameter_constraints { + match match_parameter(constraint, offering) { + Ok(parameter) => matched.push(parameter), + Err(reason) => reasons.push(reason), + } + } + if reasons.is_empty() { + eligible.push((asset, offering, matched)); + } else { + rejected.push(rejected_candidate(asset, offering, reasons)); + } + } + } + eligible.sort_by(|left, right| { + (&left.0.identity, &left.1.identity).cmp(&(&right.0.identity, &right.1.identity)) + }); + rejected + .sort_by(|left, right| (&left.asset, &left.offering).cmp(&(&right.asset, &right.offering))); + if eligible.is_empty() { + return Err(FacilityAllocationError::NoEligibleOffering { + requirement: instance.to_owned(), + capability_kind: requirement.capability_kind.clone(), + candidate_count: rejected.len(), + rejections: rejected, + }); + } + if eligible.len() > 1 { + let candidates = eligible + .into_iter() + .map(|(asset, offering, _)| eligible_candidate(asset, offering)) + .collect::>(); + return Err(FacilityAllocationError::AmbiguousOffering { + requirement: instance.to_owned(), + candidate_count: candidates.len(), + candidates, + }); + } + let (asset, offering, parameters) = eligible.pop().expect("one eligible candidate remains"); + let adapter = select_adapter( + adapters, + asset.identity.as_str(), + offering.identity.as_str(), + )?; + Ok(RequirementAllocation { + requirement_instance: instance.to_owned(), + requirement_template: requirement.id.clone(), + capability_kind: requirement.capability_kind.clone(), + minimum_qualification: requirement.minimum_qualification.iri().to_owned(), + accepted_control_modes, + offering: offering.identity.as_str().to_owned(), + asset: asset.identity.as_str().to_owned(), + observed_qualification: offering.qualification.iri().to_owned(), + control_mode: offering.control_mode.iri().to_owned(), + parameters, + adapter, + rejected_candidates: rejected, + }) +} + +fn match_parameter( + constraint: &CapabilityParameterConstraint, + offering: &FacilityCapabilityOffering, +) -> Result { + let Some(parameter) = offering + .parameters + .iter() + .find(|parameter| parameter.property_kind.as_str() == constraint.property_kind) + else { + return Err(CandidateRejectionReason::MissingParameter { + property_kind: constraint.property_kind.clone(), + }); + }; + let observed_unit = parameter.unit.as_ref().map(|unit| unit.as_str().to_owned()); + if constraint.unit != observed_unit { + return Err(CandidateRejectionReason::UnitMismatch { + property_kind: constraint.property_kind.clone(), + required: constraint.unit.clone(), + observed: observed_unit, + }); + } + match values_equal(&constraint.value, ¶meter.value) { + Some(true) => Ok(MatchedCapabilityParameter { + argument: constraint.argument.clone(), + property_kind: constraint.property_kind.clone(), + relation: constraint.relation, + required: constraint.value.clone(), + required_unit: constraint.unit.clone(), + offering_parameter: parameter.identity.as_str().to_owned(), + observed: allocation_value(parameter), + observed_unit: parameter.unit.as_ref().map(|unit| unit.as_str().to_owned()), + }), + Some(false) => Err(CandidateRejectionReason::ValueMismatch { + property_kind: constraint.property_kind.clone(), + required: render_requirement_value(&constraint.value), + observed: render_observed_value(¶meter.value), + }), + None => Err(CandidateRejectionReason::UnsupportedRequirementValue { + property_kind: constraint.property_kind.clone(), + }), + } +} + +fn values_equal(required: &TypedExpression, observed: &FacilityScalarValue) -> Option { + if let Some(required) = numeric_requirement(required) { + let observed = match observed { + FacilityScalarValue::Integer(value) | FacilityScalarValue::Real(value) => { + value.parse::().ok()? + } + FacilityScalarValue::Text(_) + | FacilityScalarValue::Boolean(_) + | FacilityScalarValue::Iri(_) => return Some(false), + }; + return Some(required == observed); + } + match (&required.value, observed) { + (CheckedExpression::String { value: required }, FacilityScalarValue::Text(observed)) => { + Some(required == observed) + } + _ => None, + } +} + +fn numeric_requirement(required: &TypedExpression) -> Option { + match &required.value { + CheckedExpression::Integer { value } => Some(*value as f64), + CheckedExpression::Decimal { text } => text.parse().ok(), + CheckedExpression::Quantity { magnitude, .. } => magnitude.parse().ok(), + CheckedExpression::Unary { operator, operand } if operator == "negate" => { + numeric_requirement(operand).map(|value| -value) + } + CheckedExpression::Reference { .. } + | CheckedExpression::List { .. } + | CheckedExpression::Call { .. } + | CheckedExpression::Construct { .. } + | CheckedExpression::Field { .. } + | CheckedExpression::Unary { .. } + | CheckedExpression::Binary { .. } + | CheckedExpression::String { .. } => None, + } +} + +fn allocation_value(parameter: &FacilityCapabilityParameter) -> AllocationScalarValue { + match ¶meter.value { + FacilityScalarValue::Text(value) => AllocationScalarValue::Text { + value: value.clone(), + }, + FacilityScalarValue::Integer(value) => AllocationScalarValue::Integer { + value: value.clone(), + }, + FacilityScalarValue::Real(value) => AllocationScalarValue::Real { + value: value.clone(), + }, + FacilityScalarValue::Boolean(value) => AllocationScalarValue::Boolean { value: *value }, + FacilityScalarValue::Iri(value) => AllocationScalarValue::Iri { + value: value.as_str().to_owned(), + }, + } +} + +fn render_requirement_value(value: &TypedExpression) -> String { + match &value.value { + CheckedExpression::Integer { value } => value.to_string(), + CheckedExpression::Decimal { text } => text.clone(), + CheckedExpression::String { value } => value.clone(), + CheckedExpression::Quantity { magnitude, .. } => magnitude.clone(), + CheckedExpression::Unary { operator, operand } if operator == "negate" => { + format!("-{}", render_requirement_value(operand)) + } + _ => "dynamic expression".to_owned(), + } +} + +fn render_observed_value(value: &FacilityScalarValue) -> String { + match value { + FacilityScalarValue::Text(value) + | FacilityScalarValue::Integer(value) + | FacilityScalarValue::Real(value) => value.clone(), + FacilityScalarValue::Boolean(value) => value.to_string(), + FacilityScalarValue::Iri(value) => value.as_str().to_owned(), + } +} + +fn select_adapter( + adapters: Option<&AdapterBindingSnapshot>, + asset: &str, + offering: &str, +) -> Result, FacilityAllocationError> { + let Some(adapters) = adapters else { + return Ok(None); + }; + let mut candidates = adapters + .bindings + .iter() + .filter(|binding| binding.asset == asset) + .filter(|binding| { + binding + .offerings + .iter() + .any(|candidate| candidate.offering == offering && candidate.planning_eligible) + }) + .collect::>(); + candidates.sort_by(|left, right| left.driver.cmp(&right.driver)); + if candidates.len() > 1 { + return Err(FacilityAllocationError::AmbiguousAdapter { + offering: offering.to_owned(), + asset: asset.to_owned(), + drivers: candidates + .iter() + .map(|binding| binding.driver.as_str()) + .collect::>() + .join(", "), + }); + } + Ok(candidates.pop().map(|binding| AllocatedAdapter { + driver: binding.driver.clone(), + profile_path: binding.profile_path.clone(), + profile_sha256: binding.profile_sha256.clone(), + features: binding.features.clone(), + accepted_run_formats: binding.accepted_run_formats.clone(), + emitted_run_formats: binding.emitted_run_formats.clone(), + })) +} + +fn rejected_candidate( + asset: &FacilityAsset, + offering: &FacilityCapabilityOffering, + reasons: Vec, +) -> RejectedCapabilityCandidate { + RejectedCapabilityCandidate { + offering: offering.identity.as_str().to_owned(), + asset: asset.identity.as_str().to_owned(), + observed_qualification: offering.qualification.iri().to_owned(), + control_mode: offering.control_mode.iri().to_owned(), + reasons, + } +} + +fn eligible_candidate( + asset: &FacilityAsset, + offering: &FacilityCapabilityOffering, +) -> EligibleCapabilityCandidate { + EligibleCapabilityCandidate { + offering: offering.identity.as_str().to_owned(), + asset: asset.identity.as_str().to_owned(), + observed_qualification: offering.qualification.iri().to_owned(), + control_mode: offering.control_mode.iri().to_owned(), + } +} + +fn inventory_qualification(qualification: RequirementQualification) -> Qualification { + match qualification { + RequirementQualification::Discovered => Qualification::Discovered, + RequirementQualification::Described => Qualification::Described, + RequirementQualification::Plannable => Qualification::Plannable, + RequirementQualification::Simulatable => Qualification::Simulatable, + RequirementQualification::Executable => Qualification::Executable, + RequirementQualification::Qualified => Qualification::Qualified, + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::PathBuf; + + use lab_language::compile_module; + use tempfile::TempDir; + + use crate::backend::validate_adapter_profile; + use crate::planning::{AdapterBindingRequest, AdapterBindingSnapshot}; + + use super::*; + + const INVENTORY: &str = r#"@prefix cap: . +@prefix ex: . +@prefix fac: . +@prefix sbol: . +@prefix xsd: . + +ex:facility a sbol:TopLevel, fac:Facility ; sbol:displayId "facility" ; + sbol:hasNamespace . +ex:room a sbol:TopLevel, fac:Zone ; sbol:displayId "room" ; + sbol:hasNamespace ; fac:facility ex:facility ; + fac:zoneKind fac:Room ; fac:isActive true . +ex:freezer a sbol:TopLevel, fac:Asset ; sbol:displayId "freezer" ; + sbol:hasNamespace ; fac:facility ex:facility ; + fac:assetKind fac:StorageAsset ; fac:locatedIn ex:room ; fac:isActive true ; + fac:capability . + + a sbol:Identified, fac:CapabilityOffering ; sbol:displayId "cold_storage" ; + fac:capabilityKind cap:ColdStorage ; fac:qualification fac:Plannable ; + fac:controlMode fac:ManualControl ; fac:isActive true ; + fac:parameter . + + a sbol:Identified, fac:PropertyValue ; sbol:displayId "temperature" ; + fac:propertyKind cap:Temperature ; fac:realValue "-80.0"^^xsd:double ; + fac:unit . +"#; + + fn inventory(contents: &str) -> (TempDir, InventorySnapshot) { + let directory = TempDir::new().unwrap(); + fs::write(directory.path().join("inventory.ttl"), contents).unwrap(); + let snapshot = InventorySnapshot::load(directory.path(), "inventory.ttl", None).unwrap(); + (directory, snapshot) + } + + fn requirements() -> (CapabilityRequirements, CapabilityRequirementInstances) { + let module = compile_module( + r#"use std.lab.plasmid + +workflow main(plasmid: Material) -> Material: + stored <- store plasmid at -80 C + return stored +"#, + ) + .unwrap(); + let requirements = CapabilityRequirements::extract(&[&module]).unwrap(); + let instances = requirements + .instantiate_reachable(&[&module], "standalone", "main") + .unwrap(); + (requirements, instances) + } + + fn liquid_requirements() -> (CapabilityRequirements, CapabilityRequirementInstances) { + let module = compile_module( + r#"use std.lab.plasmid + +workflow main(culture: Material) -> Material: + diluted <- dilute culture + return diluted +"#, + ) + .unwrap(); + let requirements = CapabilityRequirements::extract(&[&module]).unwrap(); + let instances = requirements + .instantiate_reachable(&[&module], "standalone", "main") + .unwrap(); + (requirements, instances) + } + + #[test] + fn allocates_an_exact_parameterized_offering_without_requiring_an_adapter() { + let (_directory, inventory) = inventory(INVENTORY); + let (requirements, instances) = requirements(); + + let allocation = + FacilityAllocation::allocate(&requirements, &instances, &inventory, None).unwrap(); + + assert_eq!( + allocation.schema_version, + FACILITY_ALLOCATION_SCHEMA_VERSION + ); + assert_eq!(allocation.allocations.len(), 1); + let selected = &allocation.allocations[0]; + assert_eq!(selected.asset, "https://example.org/facility/freezer"); + assert_eq!( + selected.offering, + "https://example.org/facility/freezer/cold_storage" + ); + assert!(selected.adapter.is_none()); + assert_eq!(selected.parameters.len(), 1); + assert_eq!( + selected.parameters[0].offering_parameter, + "https://example.org/facility/freezer/cold_storage/temperature" + ); + assert_eq!( + selected.parameters[0].observed, + AllocationScalarValue::Real { + value: "-80.0".to_owned() + } + ); + let json = serde_json::to_string(&allocation).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + allocation + ); + } + + #[test] + fn reports_typed_parameter_mismatch_instead_of_selecting_the_asset() { + let mismatched = INVENTORY.replace("-80.0", "-20.0"); + let (_directory, inventory) = inventory(&mismatched); + let (requirements, instances) = requirements(); + + let error = + FacilityAllocation::allocate(&requirements, &instances, &inventory, None).unwrap_err(); + + let FacilityAllocationError::NoEligibleOffering { rejections, .. } = error else { + panic!("expected a no-candidate diagnostic") + }; + assert!(matches!( + rejections[0].reasons.as_slice(), + [CandidateRejectionReason::ValueMismatch { .. }] + )); + } + + #[test] + fn freezes_the_single_explicit_planning_adapter_when_one_is_available() { + let contents = INVENTORY + .replace("cap:ColdStorage", "cap:LiquidHandling") + .replace("fac:ManualControl", "fac:ReviewedFileControl"); + let (_directory, inventory) = inventory(&contents); + let bindings = AdapterBindingSnapshot::resolve( + &inventory, + vec![AdapterBindingRequest { + asset: "https://example.org/facility/freezer".to_owned(), + driver: "hamilton.star".to_owned(), + profile_path: PathBuf::from("adapters/star.toml"), + profile: validate_adapter_profile("hamilton.star", "star", "").unwrap(), + }], + ) + .unwrap(); + let (requirements, instances) = liquid_requirements(); + + let allocation = + FacilityAllocation::allocate(&requirements, &instances, &inventory, Some(&bindings)) + .unwrap(); + + let adapter = allocation.allocations[0].adapter.as_ref().unwrap(); + assert_eq!(adapter.driver, "hamilton.star"); + assert_eq!(adapter.profile_path, PathBuf::from("adapters/star.toml")); + assert_eq!(adapter.profile_sha256.len(), 64); + } + + #[test] + fn refuses_to_turn_deterministic_candidate_ordering_into_allocation_policy() { + let second = format!( + r#"{INVENTORY} +ex:freezer_b a sbol:TopLevel, fac:Asset ; sbol:displayId "freezer_b" ; + sbol:hasNamespace ; fac:facility ex:facility ; + fac:assetKind fac:StorageAsset ; fac:locatedIn ex:room ; fac:isActive true ; + fac:capability . + + a sbol:Identified, fac:CapabilityOffering ; sbol:displayId "cold_storage" ; + fac:capabilityKind cap:ColdStorage ; fac:qualification fac:Plannable ; + fac:controlMode fac:ManualControl ; fac:isActive true ; + fac:parameter . + + a sbol:Identified, fac:PropertyValue ; sbol:displayId "temperature" ; + fac:propertyKind cap:Temperature ; fac:realValue "-80.0"^^xsd:double ; + fac:unit . +"#, + ); + let (_directory, inventory) = inventory(&second); + let (requirements, instances) = requirements(); + + let error = + FacilityAllocation::allocate(&requirements, &instances, &inventory, None).unwrap_err(); + + let FacilityAllocationError::AmbiguousOffering { candidates, .. } = error else { + panic!("expected an ambiguity") + }; + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0].asset, "https://example.org/facility/freezer"); + assert_eq!( + candidates[1].asset, + "https://example.org/facility/freezer_b" + ); + } +} diff --git a/crates/lab-compiler/src/planning/capability.rs b/crates/lab-compiler/src/planning/capability.rs new file mode 100644 index 0000000..9e77d60 --- /dev/null +++ b/crates/lab-compiler/src/planning/capability.rs @@ -0,0 +1,1057 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use lab_language::{ + CheckedActionArgument, CheckedDeclaration, CheckedExpression, CheckedField, CheckedModule, + CheckedStatement, CheckedType, OwnershipMode, ResolvedAction, TypedExpression, is_absolute_iri, +}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +pub const CAPABILITY_REQUIREMENTS_SCHEMA_VERSION: &str = "lab.capability-requirements.v2"; +pub const CAPABILITY_REQUIREMENT_INSTANCES_SCHEMA_VERSION: &str = + "lab.capability-requirement-instances.v2"; + +/// Requirement templates derived from checked workflow definitions. +/// +/// These are not allocations. A later planning pass instantiates reachable workflow templates, +/// refines composite requirements, and binds the resulting operational requirements to exact +/// SBOLInventory offerings. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityRequirements { + pub schema_version: String, + pub requirements: Vec, +} + +impl CapabilityRequirements { + pub fn extract(modules: &[&CheckedModule]) -> Result { + let mut requirements = Vec::new(); + for module in modules { + for declaration in &module.declarations { + let CheckedDeclaration::Workflow { name, body, .. } = declaration else { + continue; + }; + collect_block( + module.module.as_str(), + name, + StatementBlock::WorkflowBody, + body, + &mut Vec::new(), + &mut requirements, + )?; + } + } + requirements.sort_by(|left, right| left.id.cmp(&right.id)); + if let Some(duplicate) = requirements + .windows(2) + .find(|pair| pair[0].id == pair[1].id) + .map(|pair| pair[0].id.clone()) + { + return Err(CapabilityRequirementError::DuplicateId { id: duplicate }); + } + Ok(Self { + schema_version: CAPABILITY_REQUIREMENTS_SCHEMA_VERSION.to_owned(), + requirements, + }) + } + + /// Instantiates only requirement templates reachable from one exact entry workflow. + /// + /// A workflow invoked twice produces two instances. Structural branches and loops are + /// retained conservatively as potential work, while recursive workflow expansion is rejected + /// because it cannot produce a finite reviewed plan. + pub fn instantiate_reachable( + &self, + modules: &[&CheckedModule], + entry_module: &str, + entry_workflow: &str, + ) -> Result { + let entry = WorkflowIdentity { + module: entry_module.to_owned(), + workflow: entry_workflow.to_owned(), + }; + let workflows = workflow_bodies(modules)?; + if !workflows.contains_key(&(entry.module.clone(), entry.workflow.clone())) { + return Err(CapabilityInstantiationError::MissingEntryWorkflow { + module: entry.module, + workflow: entry.workflow, + }); + } + let templates = self + .requirements + .iter() + .map(|requirement| (requirement.id.clone(), requirement)) + .collect::>(); + let mut instances = Vec::new(); + instantiate_workflow( + &entry, + &entry, + &workflows, + &templates, + &mut Vec::new(), + &mut Vec::new(), + &mut instances, + )?; + let mut seen = BTreeSet::new(); + if let Some(id) = instances + .iter() + .find_map(|instance| (!seen.insert(instance.id.clone())).then(|| instance.id.clone())) + { + return Err(CapabilityInstantiationError::DuplicateInstanceId { id }); + } + Ok(CapabilityRequirementInstances { + schema_version: CAPABILITY_REQUIREMENT_INSTANCES_SCHEMA_VERSION.to_owned(), + requirements_schema_version: self.schema_version.clone(), + entry, + instances, + }) + } +} + +/// Requirement occurrences reachable from one exact package entry workflow. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityRequirementInstances { + pub schema_version: String, + pub requirements_schema_version: String, + pub entry: WorkflowIdentity, + pub instances: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct WorkflowIdentity { + pub module: String, + pub workflow: String, +} + +/// One distinct use of a requirement template in the entry workflow's static call expansion. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityRequirementInstance { + pub id: String, + pub template: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub call_path: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkflowCallSite { + pub caller: WorkflowIdentity, + pub statement_path: Vec, + pub callee: WorkflowIdentity, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityRequirement { + pub id: String, + pub source: CapabilityRequirementSource, + pub capability_kind: String, + pub minimum_qualification: RequirementQualification, + pub accepted_control_modes: BTreeSet, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub parameter_constraints: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub value_inputs: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub material_inputs: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub value_outputs: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub material_outputs: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_requirement: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityRequirementSource { + pub module: String, + pub workflow: String, + pub statement_path: Vec, + pub operation: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct StatementPathSegment { + pub block: StatementBlock, + pub index: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum StatementBlock { + WorkflowBody, + IfBody, + ElseBody, + MatchCase { case: usize }, + ForBody, + WhenBody, +} + +impl StatementBlock { + fn id_label(self) -> String { + match self { + Self::WorkflowBody => "body".to_owned(), + Self::IfBody => "then".to_owned(), + Self::ElseBody => "else".to_owned(), + Self::MatchCase { case } => format!("case-{case}"), + Self::ForBody => "for".to_owned(), + Self::WhenBody => "when".to_owned(), + } + } +} + +/// The closed SBOLInventory qualification vocabulary, used here as a typed minimum. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum RequirementQualification { + #[serde(rename = "https://sbol.io/ns/facility#Discovered")] + Discovered, + #[serde(rename = "https://sbol.io/ns/facility#Described")] + Described, + #[serde(rename = "https://sbol.io/ns/facility#Plannable")] + Plannable, + #[serde(rename = "https://sbol.io/ns/facility#Simulatable")] + Simulatable, + #[serde(rename = "https://sbol.io/ns/facility#Executable")] + Executable, + #[serde(rename = "https://sbol.io/ns/facility#Qualified")] + Qualified, +} + +impl RequirementQualification { + pub const fn iri(self) -> &'static str { + match self { + Self::Discovered => "https://sbol.io/ns/facility#Discovered", + Self::Described => "https://sbol.io/ns/facility#Described", + Self::Plannable => "https://sbol.io/ns/facility#Plannable", + Self::Simulatable => "https://sbol.io/ns/facility#Simulatable", + Self::Executable => "https://sbol.io/ns/facility#Executable", + Self::Qualified => "https://sbol.io/ns/facility#Qualified", + } + } +} + +/// The closed SBOLInventory control-mode vocabulary, used here as a typed accepted set. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum RequirementControlMode { + #[serde(rename = "https://sbol.io/ns/facility#UnspecifiedControl")] + Unspecified, + #[serde(rename = "https://sbol.io/ns/facility#ManualControl")] + Manual, + #[serde(rename = "https://sbol.io/ns/facility#ReviewedFileControl")] + ReviewedFile, + #[serde(rename = "https://sbol.io/ns/facility#VendorSessionControl")] + VendorSession, + #[serde(rename = "https://sbol.io/ns/facility#ApiControl")] + Api, + #[serde(rename = "https://sbol.io/ns/facility#SiLA2Control")] + Sila2, + #[serde(rename = "https://sbol.io/ns/facility#OpcUaControl")] + OpcUa, +} + +impl RequirementControlMode { + const CONCRETE: [Self; 6] = [ + Self::Manual, + Self::ReviewedFile, + Self::VendorSession, + Self::Api, + Self::Sila2, + Self::OpcUa, + ]; + + pub const fn iri(self) -> &'static str { + match self { + Self::Unspecified => "https://sbol.io/ns/facility#UnspecifiedControl", + Self::Manual => "https://sbol.io/ns/facility#ManualControl", + Self::ReviewedFile => "https://sbol.io/ns/facility#ReviewedFileControl", + Self::VendorSession => "https://sbol.io/ns/facility#VendorSessionControl", + Self::Api => "https://sbol.io/ns/facility#ApiControl", + Self::Sila2 => "https://sbol.io/ns/facility#SiLA2Control", + Self::OpcUa => "https://sbol.io/ns/facility#OpcUaControl", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityParameterConstraint { + pub argument: String, + pub property_kind: String, + pub relation: ParameterRelation, + pub value: TypedExpression, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unit: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ParameterRelation { + Exact, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityMaterialInput { + pub argument: String, + pub ownership: OwnershipMode, + pub value: TypedExpression, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityValueInput { + pub argument: String, + pub ownership: OwnershipMode, + pub value: TypedExpression, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityMaterialOutput { + pub binding: String, + pub result: String, + pub r#type: CheckedType, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityValueOutput { + pub binding: String, + pub result: String, + pub r#type: CheckedType, +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum CapabilityRequirementError { + #[error("action operation `{operation}` has non-absolute capability kind `{capability_kind}`")] + InvalidCapabilityKind { + operation: String, + capability_kind: String, + }, + #[error( + "action operation `{operation}` parameter `{argument}` has non-absolute property kind `{property_kind}`" + )] + InvalidParameterKind { + operation: String, + argument: String, + property_kind: String, + }, + #[error( + "action operation `{operation}` parameter `{argument}` uses unit `{unit}` without a canonical RDF unit IRI" + )] + UnknownParameterUnit { + operation: String, + argument: String, + unit: String, + }, + #[error("capability requirement ID `{id}` occurs more than once")] + DuplicateId { id: String }, +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum CapabilityInstantiationError { + #[error("entry module `{module}` does not declare workflow `{workflow}`")] + MissingEntryWorkflow { module: String, workflow: String }, + #[error("workflow `{module}::{workflow}` occurs more than once in checked modules")] + DuplicateWorkflow { module: String, workflow: String }, + #[error( + "workflow call at `{caller}` resolves to `{module}::{workflow}`, but its checked body is unavailable" + )] + MissingWorkflowBody { + caller: String, + module: String, + workflow: String, + }, + #[error("workflow call `{operation}` at `{caller}` has no resolved callee identity")] + MissingCalleeIdentity { operation: String, caller: String }, + #[error("reachable capability template `{template}` is absent from the extracted catalog")] + MissingTemplate { template: String }, + #[error("recursive workflow expansion cannot produce a finite plan: {cycle}")] + RecursiveWorkflow { cycle: String }, + #[error("capability requirement instance ID `{id}` occurs more than once")] + DuplicateInstanceId { id: String }, +} + +fn collect_block( + module: &str, + workflow: &str, + block: StatementBlock, + statements: &[CheckedStatement], + path: &mut Vec, + requirements: &mut Vec, +) -> Result<(), CapabilityRequirementError> { + for (index, statement) in statements.iter().enumerate() { + path.push(StatementPathSegment { block, index }); + match statement { + CheckedStatement::Effect { results, action } => { + if let Some(requirement) = requirement(module, workflow, path, results, action)? { + requirements.push(requirement); + } + } + CheckedStatement::If { + body, else_body, .. + } => { + collect_block( + module, + workflow, + StatementBlock::IfBody, + body, + path, + requirements, + )?; + collect_block( + module, + workflow, + StatementBlock::ElseBody, + else_body, + path, + requirements, + )?; + } + CheckedStatement::Match { cases, .. } => { + for (case, branch) in cases.iter().enumerate() { + collect_block( + module, + workflow, + StatementBlock::MatchCase { case }, + &branch.body, + path, + requirements, + )?; + } + } + CheckedStatement::For { body, .. } => collect_block( + module, + workflow, + StatementBlock::ForBody, + body, + path, + requirements, + )?, + CheckedStatement::When { body, .. } => collect_block( + module, + workflow, + StatementBlock::WhenBody, + body, + path, + requirements, + )?, + CheckedStatement::Binding(_) + | CheckedStatement::StateUpdate { .. } + | CheckedStatement::Return { .. } + | CheckedStatement::Emit { .. } => {} + } + path.pop(); + } + Ok(()) +} + +fn requirement( + module: &str, + workflow: &str, + path: &[StatementPathSegment], + bindings: &[CheckedField], + action: &ResolvedAction, +) -> Result, CapabilityRequirementError> { + let Some(capability_kind) = action.capability.as_ref() else { + return Ok(None); + }; + if !is_absolute_iri(capability_kind) { + return Err(CapabilityRequirementError::InvalidCapabilityKind { + operation: action.operation.clone(), + capability_kind: capability_kind.clone(), + }); + } + let PartitionedArguments { + materials: material_inputs, + values: value_inputs, + parameters: parameter_constraints, + } = partition_arguments(action)?; + let mut material_outputs = Vec::new(); + let mut value_outputs = Vec::new(); + for (binding, result) in bindings.iter().zip(&action.results) { + if contains_material(&binding.r#type) { + material_outputs.push(CapabilityMaterialOutput { + binding: binding.name.clone(), + result: result.name.clone(), + r#type: binding.r#type.clone(), + }); + } else { + value_outputs.push(CapabilityValueOutput { + binding: binding.name.clone(), + result: result.name.clone(), + r#type: binding.r#type.clone(), + }); + } + } + Ok(Some(CapabilityRequirement { + id: requirement_id(module, workflow, path), + source: CapabilityRequirementSource { + module: module.to_owned(), + workflow: workflow.to_owned(), + statement_path: path.to_vec(), + operation: action.operation.clone(), + }, + capability_kind: capability_kind.clone(), + minimum_qualification: RequirementQualification::Plannable, + accepted_control_modes: RequirementControlMode::CONCRETE.into_iter().collect(), + parameter_constraints, + value_inputs, + material_inputs, + value_outputs, + material_outputs, + parent_requirement: None, + })) +} + +struct PartitionedArguments { + materials: Vec, + values: Vec, + parameters: Vec, +} + +fn partition_arguments( + action: &ResolvedAction, +) -> Result { + let mut materials = Vec::new(); + let mut values = Vec::new(); + let mut parameters = Vec::new(); + for argument in &action.arguments { + if contains_material(&argument.value.r#type) { + materials.push(CapabilityMaterialInput { + argument: argument.name.clone(), + ownership: argument.mode, + value: argument.value.clone(), + }); + } else if let Some(property_kind) = &argument.parameter_kind { + if !is_absolute_iri(property_kind) { + return Err(CapabilityRequirementError::InvalidParameterKind { + operation: action.operation.clone(), + argument: argument.name.clone(), + property_kind: property_kind.clone(), + }); + } + parameters.push(CapabilityParameterConstraint { + argument: argument.name.clone(), + property_kind: property_kind.clone(), + relation: ParameterRelation::Exact, + value: argument.value.clone(), + unit: canonical_parameter_unit(action, argument)?, + }); + } else { + values.push(CapabilityValueInput { + argument: argument.name.clone(), + ownership: argument.mode, + value: argument.value.clone(), + }); + } + } + Ok(PartitionedArguments { + materials, + values, + parameters, + }) +} + +fn contains_material(r#type: &CheckedType) -> bool { + match r#type { + CheckedType::Named { name, .. } => name == "Material", + CheckedType::Union { alternatives } => alternatives.iter().any(contains_material), + CheckedType::List { element } => contains_material(element), + CheckedType::Quantity { .. } + | CheckedType::Any { .. } + | CheckedType::Integer + | CheckedType::Decimal + | CheckedType::String + | CheckedType::Bool + | CheckedType::None => false, + } +} + +fn canonical_parameter_unit( + action: &ResolvedAction, + argument: &CheckedActionArgument, +) -> Result, CapabilityRequirementError> { + let CheckedExpression::Quantity { unit, .. } = &argument.value.value else { + return Ok(None); + }; + let iri = match unit.as_str() { + "C" => "http://qudt.org/vocab/unit/DEG_C", + "h" => "http://qudt.org/vocab/unit/HR", + "min" => "http://qudt.org/vocab/unit/MIN", + "uL" => "http://qudt.org/vocab/unit/MicroL", + _ => { + return Err(CapabilityRequirementError::UnknownParameterUnit { + operation: action.operation.clone(), + argument: argument.name.clone(), + unit: unit.clone(), + }); + } + }; + Ok(Some(iri.to_owned())) +} + +fn requirement_id(module: &str, workflow: &str, path: &[StatementPathSegment]) -> String { + let path = render_statement_path(path); + format!("{module}::{workflow}::{path}") +} + +fn render_statement_path(path: &[StatementPathSegment]) -> String { + path.iter() + .map(|segment| format!("{}[{}]", segment.block.id_label(), segment.index)) + .collect::>() + .join("/") +} + +fn workflow_bodies<'a>( + modules: &[&'a CheckedModule], +) -> Result, CapabilityInstantiationError> { + let mut workflows = BTreeMap::new(); + for module in modules { + for declaration in &module.declarations { + let CheckedDeclaration::Workflow { name, body, .. } = declaration else { + continue; + }; + let key = (module.module.as_str().to_owned(), name.clone()); + if workflows.insert(key.clone(), body.as_slice()).is_some() { + return Err(CapabilityInstantiationError::DuplicateWorkflow { + module: key.0, + workflow: key.1, + }); + } + } + } + Ok(workflows) +} + +#[allow(clippy::too_many_arguments)] +fn instantiate_workflow( + entry: &WorkflowIdentity, + current: &WorkflowIdentity, + workflows: &BTreeMap<(String, String), &[CheckedStatement]>, + templates: &BTreeMap, + active: &mut Vec, + call_path: &mut Vec, + instances: &mut Vec, +) -> Result<(), CapabilityInstantiationError> { + if let Some(index) = active.iter().position(|workflow| workflow == current) { + let cycle = active[index..] + .iter() + .chain(std::iter::once(current)) + .map(|workflow| format!("{}::{}", workflow.module, workflow.workflow)) + .collect::>() + .join(" -> "); + return Err(CapabilityInstantiationError::RecursiveWorkflow { cycle }); + } + let key = (current.module.clone(), current.workflow.clone()); + let body = + workflows + .get(&key) + .ok_or_else(|| CapabilityInstantiationError::MissingWorkflowBody { + caller: call_path + .last() + .map(render_call_site) + .unwrap_or_else(|| format!("{}::{}", entry.module, entry.workflow)), + module: current.module.clone(), + workflow: current.workflow.clone(), + })?; + active.push(current.clone()); + let result = instantiate_block( + entry, + current, + StatementBlock::WorkflowBody, + body, + workflows, + templates, + active, + call_path, + &mut Vec::new(), + instances, + ); + active.pop(); + result +} + +#[allow(clippy::too_many_arguments)] +fn instantiate_block( + entry: &WorkflowIdentity, + current: &WorkflowIdentity, + block: StatementBlock, + statements: &[CheckedStatement], + workflows: &BTreeMap<(String, String), &[CheckedStatement]>, + templates: &BTreeMap, + active: &mut Vec, + call_path: &mut Vec, + path: &mut Vec, + instances: &mut Vec, +) -> Result<(), CapabilityInstantiationError> { + for (index, statement) in statements.iter().enumerate() { + path.push(StatementPathSegment { block, index }); + match statement { + CheckedStatement::Effect { action, .. } => { + if action.capability.is_some() { + let template = requirement_id(¤t.module, ¤t.workflow, path); + if !templates.contains_key(&template) { + return Err(CapabilityInstantiationError::MissingTemplate { template }); + } + instances.push(CapabilityRequirementInstance { + id: requirement_instance_id(entry, call_path, &template), + template, + call_path: call_path.clone(), + }); + } + if let Some(callee) = &action.callee { + let callee = WorkflowIdentity { + module: callee.module.as_str().to_owned(), + workflow: callee.local.clone(), + }; + let call_site = WorkflowCallSite { + caller: current.clone(), + statement_path: path.clone(), + callee: callee.clone(), + }; + if !workflows.contains_key(&(callee.module.clone(), callee.workflow.clone())) { + return Err(CapabilityInstantiationError::MissingWorkflowBody { + caller: render_call_site(&call_site), + module: callee.module, + workflow: callee.workflow, + }); + } + call_path.push(call_site); + let result = instantiate_workflow( + entry, &callee, workflows, templates, active, call_path, instances, + ); + call_path.pop(); + result?; + } else if action.operation.starts_with("workflow.") { + return Err(CapabilityInstantiationError::MissingCalleeIdentity { + operation: action.operation.clone(), + caller: format!( + "{}::{}::{}", + current.module, + current.workflow, + render_statement_path(path) + ), + }); + } + } + CheckedStatement::If { + body, else_body, .. + } => { + instantiate_block( + entry, + current, + StatementBlock::IfBody, + body, + workflows, + templates, + active, + call_path, + path, + instances, + )?; + instantiate_block( + entry, + current, + StatementBlock::ElseBody, + else_body, + workflows, + templates, + active, + call_path, + path, + instances, + )?; + } + CheckedStatement::Match { cases, .. } => { + for (case, branch) in cases.iter().enumerate() { + instantiate_block( + entry, + current, + StatementBlock::MatchCase { case }, + &branch.body, + workflows, + templates, + active, + call_path, + path, + instances, + )?; + } + } + CheckedStatement::For { body, .. } => instantiate_block( + entry, + current, + StatementBlock::ForBody, + body, + workflows, + templates, + active, + call_path, + path, + instances, + )?, + CheckedStatement::When { body, .. } => instantiate_block( + entry, + current, + StatementBlock::WhenBody, + body, + workflows, + templates, + active, + call_path, + path, + instances, + )?, + CheckedStatement::Binding(_) + | CheckedStatement::StateUpdate { .. } + | CheckedStatement::Return { .. } + | CheckedStatement::Emit { .. } => {} + } + path.pop(); + } + Ok(()) +} + +fn requirement_instance_id( + entry: &WorkflowIdentity, + call_path: &[WorkflowCallSite], + template: &str, +) -> String { + let mut parts = vec![format!("{}::{}", entry.module, entry.workflow)]; + parts.extend(call_path.iter().map(render_call_site)); + parts.push(template.to_owned()); + parts.join("/") +} + +fn render_call_site(call: &WorkflowCallSite) -> String { + format!( + "{}::{}::{}=>{}::{}", + call.caller.module, + call.caller.workflow, + render_statement_path(&call.statement_path), + call.callee.module, + call.callee.workflow + ) +} + +#[cfg(test)] +mod tests { + use lab_language::{CheckedDeclaration, CheckedStatement, compile_module}; + + use super::*; + + const SOURCE: &str = r#"use std.lab.plasmid + +workflow preserve(plasmid: Material) -> Material: + stored <- store plasmid at -80 C + return stored +"#; + + #[test] + fn extracts_typed_requirement_facts_without_allocating_an_asset() { + let module = compile_module(SOURCE).unwrap(); + + let catalog = CapabilityRequirements::extract(&[&module]).unwrap(); + + assert_eq!( + catalog.schema_version, + CAPABILITY_REQUIREMENTS_SCHEMA_VERSION + ); + assert_eq!(catalog.requirements.len(), 1); + let requirement = &catalog.requirements[0]; + assert_eq!(requirement.id, "standalone::preserve::body[0]"); + assert_eq!( + requirement.capability_kind, + "https://sbol.io/ns/capability#ColdStorage" + ); + assert_eq!( + requirement.minimum_qualification, + RequirementQualification::Plannable + ); + assert_eq!(requirement.accepted_control_modes.len(), 6); + assert_eq!(requirement.material_inputs.len(), 1); + assert_eq!(requirement.material_inputs[0].argument, "material"); + assert_eq!( + requirement.material_inputs[0].ownership, + OwnershipMode::Take + ); + assert_eq!(requirement.material_outputs.len(), 1); + assert_eq!(requirement.material_outputs[0].binding, "stored"); + assert_eq!(requirement.material_outputs[0].result, "material"); + assert!(requirement.value_inputs.is_empty()); + assert!(requirement.value_outputs.is_empty()); + assert_eq!(requirement.parameter_constraints.len(), 1); + assert_eq!(requirement.parameter_constraints[0].argument, "temperature"); + assert_eq!( + requirement.parameter_constraints[0].property_kind, + "https://sbol.io/ns/capability#Temperature" + ); + assert_eq!( + requirement.parameter_constraints[0].unit.as_deref(), + Some("http://qudt.org/vocab/unit/DEG_C") + ); + assert_eq!( + requirement.parameter_constraints[0].value.r#type, + CheckedType::Quantity { + unit: "C".to_owned() + } + ); + assert!(requirement.parent_requirement.is_none()); + + let json = serde_json::to_string(&catalog).unwrap(); + assert!(json.contains("https://sbol.io/ns/facility#Plannable")); + assert!(json.contains("https://sbol.io/ns/facility#ManualControl")); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + catalog + ); + } + + #[test] + fn workflow_calls_do_not_duplicate_the_callees_requirement_template() { + let module = compile_module( + r#"use std.lab.plasmid + +workflow preserve(plasmid: Material) -> Material: + stored <- store plasmid at -80 C + return stored + +workflow main(plasmid: Material) -> Material: + stored <- preserve plasmid + return stored +"#, + ) + .unwrap(); + + let catalog = CapabilityRequirements::extract(&[&module]).unwrap(); + + assert_eq!(catalog.requirements.len(), 1); + assert_eq!(catalog.requirements[0].source.workflow, "preserve"); + } + + #[test] + fn instantiates_only_reachable_requirements_and_distinguishes_two_call_sites() { + let module = compile_module( + r#"use std.lab.plasmid + +workflow preserve(plasmid: Material) -> Material: + stored <- store plasmid at -80 C + return stored + +workflow never_called(plasmid: Material) -> Material: + stored <- store plasmid at -20 C + return stored + +workflow main(plasmid: Material) -> Material: + first <- preserve plasmid + second <- preserve first + return second +"#, + ) + .unwrap(); + let catalog = CapabilityRequirements::extract(&[&module]).unwrap(); + + let instances = catalog + .instantiate_reachable(&[&module], "standalone", "main") + .unwrap(); + + assert_eq!( + instances.schema_version, + CAPABILITY_REQUIREMENT_INSTANCES_SCHEMA_VERSION + ); + assert_eq!(instances.entry.module, "standalone"); + assert_eq!(instances.entry.workflow, "main"); + assert_eq!(instances.instances.len(), 2); + assert_ne!(instances.instances[0].id, instances.instances[1].id); + assert!( + instances + .instances + .iter() + .all(|instance| instance.template == "standalone::preserve::body[0]") + ); + assert_eq!(instances.instances[0].call_path.len(), 1); + assert_eq!( + instances.instances[0].call_path[0].callee, + WorkflowIdentity { + module: "standalone".to_owned(), + workflow: "preserve".to_owned(), + } + ); + let json = serde_json::to_string(&instances).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + instances + ); + } + + #[test] + fn rejects_recursive_workflow_expansion() { + let module = compile_module( + r#"use std.lab.plasmid + +workflow first(plasmid: Material) -> Material: + next <- second plasmid + return next + +workflow second(plasmid: Material) -> Material: + next <- first plasmid + return next + +workflow main(plasmid: Material) -> Material: + result <- first plasmid + return result +"#, + ) + .unwrap(); + let catalog = CapabilityRequirements::extract(&[&module]).unwrap(); + + let error = catalog + .instantiate_reachable(&[&module], "standalone", "main") + .unwrap_err(); + + assert_eq!( + error, + CapabilityInstantiationError::RecursiveWorkflow { + cycle: "standalone::first -> standalone::second -> standalone::first".to_owned(), + } + ); + } + + #[test] + fn rejects_a_non_absolute_capability_even_in_previously_checked_ir() { + let mut module = compile_module(SOURCE).unwrap(); + let CheckedDeclaration::Workflow { body, .. } = &mut module.declarations[0] else { + panic!("the fixture begins with a workflow") + }; + let CheckedStatement::Effect { action, .. } = &mut body[0] else { + panic!("the workflow begins with an effect") + }; + action.capability = Some("cold_storage".to_owned()); + + let error = CapabilityRequirements::extract(&[&module]).unwrap_err(); + + assert_eq!( + error, + CapabilityRequirementError::InvalidCapabilityKind { + operation: "std.lab.plasmid.store".to_owned(), + capability_kind: "cold_storage".to_owned(), + } + ); + } + + #[test] + fn rejects_a_non_absolute_parameter_kind_even_in_previously_checked_ir() { + let mut module = compile_module(SOURCE).unwrap(); + let CheckedDeclaration::Workflow { body, .. } = &mut module.declarations[0] else { + panic!("the fixture begins with a workflow") + }; + let CheckedStatement::Effect { action, .. } = &mut body[0] else { + panic!("the workflow begins with an effect") + }; + action.arguments[1].parameter_kind = Some("temperature".to_owned()); + + let error = CapabilityRequirements::extract(&[&module]).unwrap_err(); + + assert_eq!( + error, + CapabilityRequirementError::InvalidParameterKind { + operation: "std.lab.plasmid.store".to_owned(), + argument: "temperature".to_owned(), + property_kind: "temperature".to_owned(), + } + ); + } +} diff --git a/crates/lab-compiler/src/planning/execution.rs b/crates/lab-compiler/src/planning/execution.rs new file mode 100644 index 0000000..738db26 --- /dev/null +++ b/crates/lab-compiler/src/planning/execution.rs @@ -0,0 +1,408 @@ +//! Projection of facility allocation into the reviewed generic execution-plan format. + +use std::collections::{BTreeMap, BTreeSet}; + +use lab_language::{CheckedExpression, TypedExpression}; +use lab_runfmt::{ + EXECUTION_PLAN_FORMAT, ExecutionAdapterBinding, ExecutionInventoryReference, + ExecutionLoweringBundle, ExecutionMaterialBinding, ExecutionParameterBinding, + ExecutionParameterValue, ExecutionPlanAction, ExecutionPlanDocument, ExecutionPlanNode, + ExecutionRequirementBinding, ReviewedRunDocument, +}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::{ + AllocationScalarValue, FACILITY_ALLOCATION_SCHEMA_VERSION, FacilityAllocation, + ParameterRelation, +}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExecutionPlanOptions { + /// Package-relative copy of the exact inventory graph reviewed with the plan. + pub inventory_document: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub materials: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub outputs: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub movements: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub reviewed_documents: BTreeMap, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub lowerings: Vec, +} + +impl Default for ExecutionPlanOptions { + fn default() -> Self { + Self { + inventory_document: "inventory-source.ttl".to_owned(), + materials: Vec::new(), + outputs: Vec::new(), + movements: Vec::new(), + reviewed_documents: BTreeMap::new(), + lowerings: Vec::new(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PlannedMaterialMove { + pub id: String, + pub material: String, + pub from: String, + pub to: String, + pub instructions: String, + pub after_requirement: String, + pub before_requirement: String, +} + +pub fn build_execution_plan( + allocation: &FacilityAllocation, + mut options: ExecutionPlanOptions, +) -> Result { + if allocation.schema_version != FACILITY_ALLOCATION_SCHEMA_VERSION { + return Err(ExecutionPlanBuildError::WrongAllocationSchema { + found: allocation.schema_version.clone(), + }); + } + let mut requirement_nodes = BTreeMap::new(); + let mut requirements = Vec::new(); + let mut nodes = Vec::new(); + let mut previous = None; + for (index, selected) in allocation.allocations.iter().enumerate() { + let id = format!("execute-{:04}", index + 1); + requirement_nodes.insert(selected.requirement_instance.clone(), id.clone()); + let document = options + .reviewed_documents + .remove(&selected.requirement_instance); + if let Some(document) = &document { + let Some(adapter) = selected.adapter.as_ref() else { + return Err(ExecutionPlanBuildError::DocumentWithoutAdapter { + requirement: selected.requirement_instance.clone(), + }); + }; + if !adapter.emitted_run_formats.contains(&document.format) { + return Err(ExecutionPlanBuildError::UnsupportedDocumentFormat { + requirement: selected.requirement_instance.clone(), + driver: adapter.driver.clone(), + format: document.format.clone(), + supported: render_set(&adapter.emitted_run_formats), + }); + } + } + requirements.push(ExecutionRequirementBinding { + requirement_instance: selected.requirement_instance.clone(), + requirement_template: selected.requirement_template.clone(), + capability_kind: selected.capability_kind.clone(), + offering: selected.offering.clone(), + asset: selected.asset.clone(), + minimum_qualification: selected.minimum_qualification.clone(), + observed_qualification: selected.observed_qualification.clone(), + control_mode: selected.control_mode.clone(), + parameters: selected + .parameters + .iter() + .map(|parameter| { + Ok(ExecutionParameterBinding { + argument: parameter.argument.clone(), + property_kind: parameter.property_kind.clone(), + relation: match parameter.relation { + ParameterRelation::Exact => "exact".to_owned(), + }, + required: requirement_value(¶meter.required)?, + required_unit: parameter.required_unit.clone(), + offering_parameter: parameter.offering_parameter.clone(), + observed: observed_value(¶meter.observed), + observed_unit: parameter.observed_unit.clone(), + }) + }) + .collect::, ExecutionPlanBuildError>>()?, + adapter: selected + .adapter + .as_ref() + .map(|adapter| { + let profile_path = adapter.profile_path.to_str().ok_or_else(|| { + ExecutionPlanBuildError::NonUtf8ProfilePath { + driver: adapter.driver.clone(), + } + })?; + Ok(ExecutionAdapterBinding { + driver: adapter.driver.clone(), + profile_path: profile_path.to_owned(), + profile_sha256: adapter.profile_sha256.clone(), + }) + }) + .transpose()?, + }); + nodes.push(ExecutionPlanNode { + id: id.clone(), + after: previous.into_iter().collect(), + action: ExecutionPlanAction::Execute { + requirement: selected.requirement_instance.clone(), + document, + }, + }); + previous = Some(id); + } + if let Some(requirement) = options.reviewed_documents.keys().next() { + return Err(ExecutionPlanBuildError::UnknownDocumentRequirement { + requirement: requirement.clone(), + }); + } + + for movement in options.movements { + let after = requirement_nodes + .get(&movement.after_requirement) + .ok_or_else(|| ExecutionPlanBuildError::UnknownMovementRequirement { + movement: movement.id.clone(), + requirement: movement.after_requirement.clone(), + })? + .clone(); + let before = requirement_nodes + .get(&movement.before_requirement) + .ok_or_else(|| ExecutionPlanBuildError::UnknownMovementRequirement { + movement: movement.id.clone(), + requirement: movement.before_requirement.clone(), + })? + .clone(); + let before_node = nodes + .iter_mut() + .find(|node| node.id == before) + .expect("requirement node map points into the node list"); + if !before_node.after.contains(&movement.id) { + before_node.after.push(movement.id.clone()); + before_node.after.sort(); + } + nodes.push(ExecutionPlanNode { + id: movement.id, + after: vec![after], + action: ExecutionPlanAction::MoveMaterial { + material: movement.material, + from: movement.from, + to: movement.to, + instructions: movement.instructions, + }, + }); + } + + let plan = ExecutionPlanDocument { + format: EXECUTION_PLAN_FORMAT.to_owned(), + inventory: ExecutionInventoryReference { + document: options.inventory_document, + source_sha256: allocation.inventory_sha256.clone(), + facility: allocation.facility.clone(), + }, + requirements, + materials: options.materials, + outputs: options.outputs, + lowerings: options.lowerings, + nodes, + }; + plan.validate() + .map_err(ExecutionPlanBuildError::InvalidPlan)?; + Ok(plan) +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ExecutionPlanBuildError { + #[error( + "facility allocation declares schema `{found}`, expected `{FACILITY_ALLOCATION_SCHEMA_VERSION}`" + )] + WrongAllocationSchema { found: String }, + #[error("requirement `{requirement}` has a reviewed run document but no allocated adapter")] + DocumentWithoutAdapter { requirement: String }, + #[error( + "adapter `{driver}` for requirement `{requirement}` does not emit `{format}`; supported formats: {supported}" + )] + UnsupportedDocumentFormat { + requirement: String, + driver: String, + format: String, + supported: String, + }, + #[error("reviewed run document references unknown requirement `{requirement}`")] + UnknownDocumentRequirement { requirement: String }, + #[error("adapter `{driver}` has a non-UTF-8 profile path")] + NonUtf8ProfilePath { driver: String }, + #[error("material movement `{movement}` references unknown requirement `{requirement}`")] + UnknownMovementRequirement { + movement: String, + requirement: String, + }, + #[error("requirement parameter uses a dynamic value that cannot enter a reviewed plan")] + DynamicParameter, + #[error("constructed execution plan is invalid: {0}")] + InvalidPlan(String), +} + +fn requirement_value( + value: &TypedExpression, +) -> Result { + match &value.value { + CheckedExpression::Integer { value } => { + Ok(ExecutionParameterValue::Integer(value.to_string())) + } + CheckedExpression::Decimal { text } => Ok(ExecutionParameterValue::Real(text.clone())), + CheckedExpression::String { value } => Ok(ExecutionParameterValue::Text(value.clone())), + CheckedExpression::Quantity { magnitude, .. } => Ok(if magnitude.parse::().is_ok() { + ExecutionParameterValue::Integer(magnitude.clone()) + } else { + ExecutionParameterValue::Real(magnitude.clone()) + }), + CheckedExpression::Unary { operator, operand } if operator == "negate" => { + match requirement_value(operand)? { + ExecutionParameterValue::Integer(value) => { + Ok(ExecutionParameterValue::Integer(format!("-{value}"))) + } + ExecutionParameterValue::Real(value) => { + Ok(ExecutionParameterValue::Real(format!("-{value}"))) + } + ExecutionParameterValue::Text(_) + | ExecutionParameterValue::Boolean(_) + | ExecutionParameterValue::Iri(_) => Err(ExecutionPlanBuildError::DynamicParameter), + } + } + CheckedExpression::Reference { .. } + | CheckedExpression::List { .. } + | CheckedExpression::Call { .. } + | CheckedExpression::Construct { .. } + | CheckedExpression::Field { .. } + | CheckedExpression::Unary { .. } + | CheckedExpression::Binary { .. } => Err(ExecutionPlanBuildError::DynamicParameter), + } +} + +fn observed_value(value: &AllocationScalarValue) -> ExecutionParameterValue { + match value { + AllocationScalarValue::Text { value } => ExecutionParameterValue::Text(value.clone()), + AllocationScalarValue::Integer { value } => ExecutionParameterValue::Integer(value.clone()), + AllocationScalarValue::Real { value } => ExecutionParameterValue::Real(value.clone()), + AllocationScalarValue::Boolean { value } => ExecutionParameterValue::Boolean(*value), + AllocationScalarValue::Iri { value } => ExecutionParameterValue::Iri(value.clone()), + } +} + +fn render_set(values: &BTreeSet) -> String { + if values.is_empty() { + "none".to_owned() + } else { + values.iter().cloned().collect::>().join(", ") + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use lab_runfmt::ExecutionMaterialBinding; + + use crate::planning::{AllocatedAdapter, RequirementAllocation}; + + use super::*; + + fn allocation() -> FacilityAllocation { + FacilityAllocation { + schema_version: FACILITY_ALLOCATION_SCHEMA_VERSION.to_owned(), + inventory_sha256: "a".repeat(64), + facility: "https://example.org/facility".to_owned(), + requirements_schema_version: "lab.capability-requirements.v2".to_owned(), + instances_schema_version: "lab.capability-requirement-instances.v2".to_owned(), + allocations: ["liquid", "read"] + .into_iter() + .enumerate() + .map(|(index, name)| RequirementAllocation { + requirement_instance: format!("example::main/{name}"), + requirement_template: format!("example::main::{name}"), + capability_kind: format!("https://example.org/capability/{name}"), + minimum_qualification: "https://sbol.io/ns/facility#Plannable".to_owned(), + accepted_control_modes: BTreeSet::new(), + offering: format!("https://example.org/{name}/offering"), + asset: format!("https://example.org/{name}"), + observed_qualification: "https://sbol.io/ns/facility#Executable".to_owned(), + control_mode: "https://sbol.io/ns/facility#ReviewedFileControl".to_owned(), + parameters: Vec::new(), + adapter: Some(AllocatedAdapter { + driver: format!("example.{name}"), + profile_path: PathBuf::from(format!("adapters/{name}.toml")), + profile_sha256: if index == 0 { "b" } else { "c" }.repeat(64), + features: BTreeSet::new(), + accepted_run_formats: BTreeSet::new(), + emitted_run_formats: [format!("example.{name}.v1")].into_iter().collect(), + }), + rejected_candidates: Vec::new(), + }) + .collect(), + } + } + + #[test] + fn projects_exact_bindings_and_explicit_material_movement_into_a_valid_dag() { + let allocation = allocation(); + let material = ExecutionMaterialBinding { + id: "plate".to_owned(), + component: "https://example.org/design".to_owned(), + material_lot: "https://example.org/lot".to_owned(), + }; + let plan = build_execution_plan( + &allocation, + ExecutionPlanOptions { + inventory_document: "inventory-source.ttl".to_owned(), + materials: vec![material], + outputs: Vec::new(), + movements: vec![PlannedMaterialMove { + id: "move-plate".to_owned(), + material: "plate".to_owned(), + from: "https://example.org/liquid".to_owned(), + to: "https://example.org/read".to_owned(), + instructions: "Move the plate to the reader.".to_owned(), + after_requirement: "example::main/liquid".to_owned(), + before_requirement: "example::main/read".to_owned(), + }], + reviewed_documents: BTreeMap::new(), + lowerings: Vec::new(), + }, + ) + .unwrap(); + + assert_eq!(plan.requirements.len(), 2); + assert_eq!(plan.materials.len(), 1); + assert_eq!(plan.nodes.len(), 3); + assert!(matches!( + plan.nodes[2].action, + ExecutionPlanAction::MoveMaterial { .. } + )); + assert!(plan.nodes[1].after.contains(&"move-plate".to_owned())); + plan.validate().unwrap(); + } + + #[test] + fn reviewed_child_documents_must_match_the_frozen_adapter_contract() { + let allocation = allocation(); + let documents = [( + "example::main/liquid".to_owned(), + ReviewedRunDocument { + path: "runs/liquid.json".to_owned(), + format: "wrong.format".to_owned(), + sha256: "d".repeat(64), + }, + )] + .into_iter() + .collect(); + + let error = build_execution_plan( + &allocation, + ExecutionPlanOptions { + inventory_document: "inventory-source.ttl".to_owned(), + reviewed_documents: documents, + ..ExecutionPlanOptions::default() + }, + ) + .unwrap_err(); + + assert!(matches!( + error, + ExecutionPlanBuildError::UnsupportedDocumentFormat { .. } + )); + } +} diff --git a/crates/lab-compiler/src/planning/inventory.rs b/crates/lab-compiler/src/planning/inventory.rs new file mode 100644 index 0000000..1fa71ac --- /dev/null +++ b/crates/lab-compiler/src/planning/inventory.rs @@ -0,0 +1,225 @@ +use std::collections::BTreeMap; + +use lab_language::{CheckedDeclaration, CheckedModule}; +use thiserror::Error; + +use super::model::{BuildInventory, MaterialLotBuildInventory, MaterialLotCandidates}; + +#[derive(Debug, Error)] +pub enum BuildInventoryError { + #[error( + "inventory lookup key `{symbol}` refers to both SBOL Components `{first}` and `{second}`" + )] + ConflictingDesignIdentity { + symbol: String, + first: String, + second: String, + }, +} + +impl BuildInventory { + /// Binds checked declaration identities to every active candidate lot in one facility snapshot. + /// + /// Candidate preservation is intentional. Selecting among several lots is allocation policy, + /// so dependency planning reports ambiguity if it actually needs such a declaration. + pub fn from_material_lots( + modules: &[&CheckedModule], + source_sha256: impl Into, + facility: impl Into, + lots_by_component: &BTreeMap>, + ) -> Result { + let mut materials = BTreeMap::new(); + let mut artifacts = BTreeMap::new(); + for declaration in modules.iter().flat_map(|module| module.declarations.iter()) { + match declaration { + CheckedDeclaration::Catalog { + name, + sbol_identity, + supplier_identity, + .. + } => { + insert_declaration( + &mut materials, + name, + sbol_identity.as_deref(), + lots_by_component, + )?; + if supplier_identity != name { + insert_declaration( + &mut materials, + supplier_identity, + sbol_identity.as_deref(), + lots_by_component, + )?; + } + } + CheckedDeclaration::Artifact { + name, + sbol_identity, + .. + } => insert_declaration( + &mut artifacts, + name, + sbol_identity.as_deref(), + lots_by_component, + )?, + _ => {} + } + } + Ok(Self::MaterialLots(MaterialLotBuildInventory { + source_sha256: source_sha256.into(), + facility: facility.into(), + materials, + artifacts, + })) + } +} + +fn insert_declaration( + entries: &mut BTreeMap, + lookup_key: &str, + identity: Option<&str>, + lots_by_component: &BTreeMap>, +) -> Result<(), BuildInventoryError> { + let candidate = if let Some(identity) = identity { + let mut material_lots = lots_by_component.get(identity).cloned().unwrap_or_default(); + material_lots.sort(); + material_lots.dedup(); + MaterialLotCandidates::Identified { + component: identity.to_owned(), + material_lots, + } + } else { + MaterialLotCandidates::Unidentified + }; + + if let Some(existing) = entries.get(lookup_key) { + ensure_compatible(lookup_key, existing, &candidate)?; + return Ok(()); + } + entries.insert(lookup_key.to_owned(), candidate); + Ok(()) +} + +fn ensure_compatible( + symbol: &str, + first: &MaterialLotCandidates, + second: &MaterialLotCandidates, +) -> Result<(), BuildInventoryError> { + match (first, second) { + ( + MaterialLotCandidates::Identified { + component: first, .. + }, + MaterialLotCandidates::Identified { + component: second, .. + }, + ) if first != second => Err(BuildInventoryError::ConflictingDesignIdentity { + symbol: symbol.to_owned(), + first: first.clone(), + second: second.clone(), + }), + (MaterialLotCandidates::Unidentified, MaterialLotCandidates::Identified { .. }) + | (MaterialLotCandidates::Identified { .. }, MaterialLotCandidates::Unidentified) => { + Err(BuildInventoryError::ConflictingDesignIdentity { + symbol: symbol.to_owned(), + first: render_identity(first), + second: render_identity(second), + }) + } + _ => Ok(()), + } +} + +fn render_identity(candidates: &MaterialLotCandidates) -> String { + match candidates { + MaterialLotCandidates::Unidentified => "".to_owned(), + MaterialLotCandidates::Identified { component, .. } => component.clone(), + } +} + +#[cfg(test)] +mod tests { + use lab_language::compile_module; + + use super::*; + + fn lots() -> BTreeMap> { + BTreeMap::from([( + "https://example.org/inventory/input".to_owned(), + vec!["https://example.org/inventory/input_lot".to_owned()], + )]) + } + + #[test] + fn binds_operational_aliases_to_one_exact_design_iri() { + let checked = compile_module( + r#"use std.bio.designs + +buy part source: + sbol_identity = "https://example.org/inventory/input" + supplier_identity = "SKU-1" + +build part product: + sbol_identity = "https://example.org/inventory/product" +"#, + ) + .unwrap(); + let inventory = BuildInventory::from_material_lots( + &[&checked], + "abc123", + "https://example.org/inventory/facility", + &lots(), + ) + .unwrap(); + + let BuildInventory::MaterialLots(inventory) = inventory else { + panic!("an SBOLInventory document creates semantic inventory"); + }; + assert_eq!(inventory.facility, "https://example.org/inventory/facility"); + assert!(inventory.materials.contains_key("SKU-1")); + assert!(inventory.materials.contains_key("source")); + let expected = MaterialLotCandidates::Identified { + component: "https://example.org/inventory/input".to_owned(), + material_lots: vec!["https://example.org/inventory/input_lot".to_owned()], + }; + assert_eq!(inventory.materials["SKU-1"], expected); + assert_eq!(inventory.materials["source"], expected); + assert_eq!( + inventory.artifacts["product"], + MaterialLotCandidates::Identified { + component: "https://example.org/inventory/product".to_owned(), + material_lots: Vec::new(), + } + ); + } + + #[test] + fn rejects_one_operational_key_that_names_different_designs() { + let checked = compile_module( + r#"use std.bio.designs + +buy part first: + sbol_identity = "https://example.org/inventory/input" + supplier_identity = "same-sku" + +buy part second: + sbol_identity = "https://example.org/inventory/product" + supplier_identity = "same-sku" +"#, + ) + .unwrap(); + let error = BuildInventory::from_material_lots( + &[&checked], + "abc123", + "https://example.org/inventory/facility", + &lots(), + ) + .unwrap_err(); + + assert!(matches!( + error, + BuildInventoryError::ConflictingDesignIdentity { symbol, .. } if symbol == "same-sku" + )); + } +} diff --git a/crates/lab-compiler/src/planning/lowering.rs b/crates/lab-compiler/src/planning/lowering.rs new file mode 100644 index 0000000..953149f --- /dev/null +++ b/crates/lab-compiler/src/planning/lowering.rs @@ -0,0 +1,188 @@ +//! Exact record of the adapter lowerings derived from one facility allocation. + +use std::path::PathBuf; + +use lab_runfmt::{ + ExecutionAdapterBinding, ExecutionLoweringBundle, ReviewedLoweringArtifact, + ReviewedLoweringArtifactRole, +}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +pub const FACILITY_LOWERING_SCHEMA_VERSION: &str = "lab.facility-lowering.v1"; + +/// Device artifacts emitted only after capability requirements have been allocated to a facility. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FacilityLoweringManifest { + pub schema_version: String, + pub inventory_sha256: String, + pub facility: String, + pub routes: Vec, +} + +/// One exact Asset and adapter implementation selected by allocation. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FacilityLoweringRoute { + pub id: String, + pub asset: String, + pub driver: String, + pub profile_path: PathBuf, + pub profile_sha256: String, + pub requirements: Vec, + pub output: PathBuf, + pub artifacts: Vec, +} + +/// A semantic requirement whose allocated route caused this adapter lowering to exist. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FacilityLoweredRequirement { + pub requirement_instance: String, + pub capability_kind: String, + pub offering: String, +} + +/// One immutable artifact emitted by an allocated adapter. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FacilityLoweredArtifact { + pub path: PathBuf, + pub media_type: String, + pub sha256: String, + pub role: FacilityLoweredArtifactRole, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FacilityLoweredArtifactRole { + AutomationProtocol, + OperatorDocument, + Support, +} + +/// Projects compiler lowering records into the generic reviewed-plan contract. +pub fn reviewed_lowering_bundles( + manifest: &FacilityLoweringManifest, +) -> Result, FacilityLoweringProjectionError> { + if manifest.schema_version != FACILITY_LOWERING_SCHEMA_VERSION { + return Err(FacilityLoweringProjectionError::WrongSchema { + found: manifest.schema_version.clone(), + }); + } + manifest + .routes + .iter() + .map(|route| { + let profile_path = utf8_path(&route.profile_path, &route.id, "adapter profile")?; + let artifacts = route + .artifacts + .iter() + .map(|artifact| { + let path = route.output.join(&artifact.path); + Ok(ReviewedLoweringArtifact { + path: utf8_path(&path, &route.id, "artifact")?, + media_type: artifact.media_type.clone(), + sha256: artifact.sha256.clone(), + role: match artifact.role { + FacilityLoweredArtifactRole::AutomationProtocol => { + ReviewedLoweringArtifactRole::DeviceProtocol + } + FacilityLoweredArtifactRole::OperatorDocument => { + ReviewedLoweringArtifactRole::OperatorDocument + } + FacilityLoweredArtifactRole::Support => { + ReviewedLoweringArtifactRole::Support + } + }, + format: artifact.format.clone(), + }) + }) + .collect::, FacilityLoweringProjectionError>>()?; + Ok(ExecutionLoweringBundle { + id: route.id.clone(), + asset: route.asset.clone(), + adapter: ExecutionAdapterBinding { + driver: route.driver.clone(), + profile_path, + profile_sha256: route.profile_sha256.clone(), + }, + requirements: route + .requirements + .iter() + .map(|requirement| requirement.requirement_instance.clone()) + .collect(), + artifacts, + }) + }) + .collect() +} + +fn utf8_path( + path: &std::path::Path, + route: &str, + kind: &'static str, +) -> Result { + path.to_str() + .map(str::to_owned) + .ok_or_else(|| FacilityLoweringProjectionError::NonUtf8Path { + route: route.to_owned(), + kind, + }) +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum FacilityLoweringProjectionError { + #[error( + "facility lowering declares schema `{found}`, expected `{FACILITY_LOWERING_SCHEMA_VERSION}`" + )] + WrongSchema { found: String }, + #[error("facility lowering route `{route}` has a non-UTF-8 {kind} path")] + NonUtf8Path { route: String, kind: &'static str }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_facility_lowering_becomes_one_exact_reviewed_child_bundle() { + let manifest = FacilityLoweringManifest { + schema_version: FACILITY_LOWERING_SCHEMA_VERSION.to_owned(), + inventory_sha256: "a".repeat(64), + facility: "https://example.org/facility".to_owned(), + routes: vec![FacilityLoweringRoute { + id: "opentrons-ot2-a1b2c3d4e5f6".to_owned(), + asset: "https://example.org/ot2".to_owned(), + driver: "opentrons.ot2".to_owned(), + profile_path: PathBuf::from("adapters/opentrons.ot2-profile.toml"), + profile_sha256: "b".repeat(64), + requirements: vec![FacilityLoweredRequirement { + requirement_instance: "example::main/body[0]".to_owned(), + capability_kind: "https://example.org/LiquidHandling".to_owned(), + offering: "https://example.org/ot2/liquid-handling".to_owned(), + }], + output: PathBuf::from("assets/ot2"), + artifacts: vec![FacilityLoweredArtifact { + path: PathBuf::from("wave-001/protocol.py"), + media_type: "text/x-python".to_owned(), + sha256: "c".repeat(64), + role: FacilityLoweredArtifactRole::AutomationProtocol, + format: Some("opentrons.python-protocol".to_owned()), + }], + }], + }; + + let reviewed = reviewed_lowering_bundles(&manifest).unwrap(); + assert_eq!(reviewed.len(), 1); + assert_eq!(reviewed[0].id, manifest.routes[0].id); + assert_eq!(reviewed[0].requirements, ["example::main/body[0]"]); + assert_eq!( + reviewed[0].artifacts[0].path, + "assets/ot2/wave-001/protocol.py" + ); + assert_eq!( + reviewed[0].artifacts[0].role, + ReviewedLoweringArtifactRole::DeviceProtocol + ); + } +} diff --git a/crates/lab-compiler/src/planning/mod.rs b/crates/lab-compiler/src/planning/mod.rs index db9066f..8bb52d3 100644 --- a/crates/lab-compiler/src/planning/mod.rs +++ b/crates/lab-compiler/src/planning/mod.rs @@ -1,10 +1,45 @@ //! Target-neutral planning shared by compiler backends. +mod adapters; +mod allocation; +mod capability; +mod execution; +mod inventory; +mod lowering; mod model; mod resolution; +pub use adapters::{ + ADAPTER_BINDINGS_SCHEMA_VERSION, AdapterBindingError, AdapterBindingRequest, + AdapterBindingSnapshot, BoundCapabilityOffering, BoundCapabilityParameter, + BoundCapabilityParameterValue, ResolvedAdapterBinding, +}; +pub use allocation::{ + AllocatedAdapter, AllocationScalarValue, CandidateRejectionReason, EligibleCapabilityCandidate, + FACILITY_ALLOCATION_SCHEMA_VERSION, FacilityAllocation, FacilityAllocationError, + MatchedCapabilityParameter, RejectedCapabilityCandidate, RequirementAllocation, +}; +pub use capability::{ + CAPABILITY_REQUIREMENT_INSTANCES_SCHEMA_VERSION, CAPABILITY_REQUIREMENTS_SCHEMA_VERSION, + CapabilityInstantiationError, CapabilityMaterialInput, CapabilityMaterialOutput, + CapabilityParameterConstraint, CapabilityRequirement, CapabilityRequirementError, + CapabilityRequirementInstance, CapabilityRequirementInstances, CapabilityRequirementSource, + CapabilityRequirements, CapabilityValueInput, CapabilityValueOutput, ParameterRelation, + RequirementControlMode, RequirementQualification, StatementBlock, StatementPathSegment, + WorkflowCallSite, WorkflowIdentity, +}; +pub use execution::{ + ExecutionPlanBuildError, ExecutionPlanOptions, PlannedMaterialMove, build_execution_plan, +}; +pub use inventory::BuildInventoryError; +pub use lowering::{ + FACILITY_LOWERING_SCHEMA_VERSION, FacilityLoweredArtifact, FacilityLoweredArtifactRole, + FacilityLoweredRequirement, FacilityLoweringManifest, FacilityLoweringProjectionError, + FacilityLoweringRoute, reviewed_lowering_bundles, +}; pub use model::{ ArtifactResolution, BuildAttempt, BuildGraph, BuildGraphNode, BuildInventory, - DependencyBuildManifest, DependencyBuildStatus, DependencyEdge, DependencyNode, + DependencyBuildManifest, DependencyBuildStatus, DependencyEdge, DependencyInventorySource, + DependencyNode, LegacyBuildInventory, MaterialLotBinding, MaterialLotBuildInventory, }; pub use resolution::{DependencyGraphError, resolve_dependency_graph}; diff --git a/crates/lab-compiler/src/planning/model.rs b/crates/lab-compiler/src/planning/model.rs index 2f48c3e..1b2508d 100644 --- a/crates/lab-compiler/src/planning/model.rs +++ b/crates/lab-compiler/src/planning/model.rs @@ -2,16 +2,86 @@ use std::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; -/// Materials and already-realized artifacts available before planning begins. +/// The inventory evidence available to dependency planning. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BuildInventory { + /// Exact SBOL Component-to-MaterialLot candidates from one validated facility snapshot. + MaterialLots(MaterialLotBuildInventory), + /// Temporary compatibility for packages that still carry symbolic manifest arrays. + LegacySymbols(LegacyBuildInventory), +} + +impl Default for BuildInventory { + fn default() -> Self { + Self::LegacySymbols(LegacyBuildInventory::default()) + } +} + +impl BuildInventory { + pub fn legacy( + available_materials: impl IntoIterator, + available_artifacts: impl IntoIterator, + ) -> Self { + Self::LegacySymbols(LegacyBuildInventory { + available_materials: available_materials.into_iter().collect(), + available_artifacts: available_artifacts.into_iter().collect(), + }) + } + + pub fn as_legacy_mut(&mut self) -> Option<&mut LegacyBuildInventory> { + match self { + Self::LegacySymbols(inventory) => Some(inventory), + Self::MaterialLots(_) => None, + } + } +} + +/// Symbolic inventory accepted only while existing package manifests migrate. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct BuildInventory { +pub struct LegacyBuildInventory { #[serde(default)] pub available_materials: BTreeSet, #[serde(default)] pub available_artifacts: BTreeSet, } -/// A target-neutral artifact dependency graph. +/// Exact candidate lots for the checked declarations in one program. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MaterialLotBuildInventory { + pub(crate) source_sha256: String, + pub(crate) facility: String, + pub(crate) materials: BTreeMap, + pub(crate) artifacts: BTreeMap, +} + +impl MaterialLotBuildInventory { + pub fn source_sha256(&self) -> &str { + &self.source_sha256 + } + + pub fn facility(&self) -> &str { + &self.facility + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum MaterialLotCandidates { + Unidentified, + Identified { + component: String, + material_lots: Vec, + }, +} + +/// A frozen exact binding from a workflow symbol through its Component to one physical lot. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct MaterialLotBinding { + pub symbol: String, + pub component: String, + pub material_lot: String, +} + +/// A facility-independent artifact dependency graph. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct BuildGraph { pub nodes: BTreeMap, @@ -47,6 +117,8 @@ pub struct DependencyNode { pub dependencies: Vec, pub steps: Vec, pub inventory_materials: Vec, + pub material_lot_bindings: Vec, + pub existing_material_lot: Option, pub resolution: ArtifactResolution, pub generated_in_iteration: Option, pub missing_dependencies: Vec, @@ -73,6 +145,7 @@ pub struct BuildAttempt { #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct DependencyBuildManifest { pub schema_version: String, + pub inventory: DependencyInventorySource, pub status: DependencyBuildStatus, pub roots: Vec, pub nodes: Vec, @@ -81,3 +154,13 @@ pub struct DependencyBuildManifest { pub generated_artifacts: Vec, pub existing_artifacts: Vec, } + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum DependencyInventorySource { + SbolInventory { + source_sha256: String, + facility: String, + }, + LegacySymbols, +} diff --git a/crates/lab-compiler/src/planning/resolution.rs b/crates/lab-compiler/src/planning/resolution.rs index eb0a818..a50983b 100644 --- a/crates/lab-compiler/src/planning/resolution.rs +++ b/crates/lab-compiler/src/planning/resolution.rs @@ -2,9 +2,11 @@ use std::collections::{BTreeMap, BTreeSet}; use thiserror::Error; +use crate::planning::model::MaterialLotCandidates; use crate::planning::{ ArtifactResolution, BuildAttempt, BuildGraph, BuildInventory, DependencyBuildManifest, - DependencyBuildStatus, DependencyEdge, DependencyNode, + DependencyBuildStatus, DependencyEdge, DependencyInventorySource, DependencyNode, + MaterialLotBinding, }; #[derive(Debug, Error, PartialEq, Eq)] @@ -19,31 +21,65 @@ pub enum DependencyGraphError { a catalogued name that was renamed leaves its old identity here" )] UnusedInventoryMaterial { material: String }, + #[error( + "{kind} `{symbol}` has no exact sbol_identity; SBOLInventory matching never uses declaration names or display IDs" + )] + MissingDesignIdentity { kind: &'static str, symbol: String }, + #[error( + "{kind} `{symbol}` realizes SBOL Component `{component}` through several active MaterialLots ({material_lots}); allocation policy must select one" + )] + AmbiguousMaterialLot { + kind: &'static str, + symbol: String, + component: String, + material_lots: String, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum Availability { + Missing, + Legacy, + MaterialLot(MaterialLotBinding), +} + +impl Availability { + fn is_available(&self) -> bool { + !matches!(self, Self::Missing) + } + + fn binding(&self) -> Option<&MaterialLotBinding> { + match self { + Self::MaterialLot(binding) => Some(binding), + Self::Missing | Self::Legacy => None, + } + } } /// Resolve graph waves against inventory without interpreting any biological -/// operation, execution target, or assembly hierarchy. +/// operation, execution device, or assembly hierarchy. pub fn resolve_dependency_graph( graph: &BuildGraph, inventory: &BuildInventory, ) -> Result { - // A catalogued name supplies its own external identity, so a rename that - // was meant to keep the supplier's name silently leaves the old one behind - // in the manifest. Declaring stock this build never asks for is that - // mistake, and a typo, and a stale entry — all worth stopping for. - let required = graph - .nodes - .values() - .flat_map(|node| node.required_materials.iter().cloned()) - .collect::>(); - if let Some(material) = inventory - .available_materials - .iter() - .find(|material| !required.contains(*material)) - { - return Err(DependencyGraphError::UnusedInventoryMaterial { - material: material.clone(), - }); + if let BuildInventory::LegacySymbols(inventory) = inventory { + // A legacy manifest is authored by hand, so an unused name is likely a + // typo or stale entry. A semantic catalog may contain any number of + // unrelated lots and is never subjected to this check. + let required = graph + .nodes + .values() + .flat_map(|node| node.required_materials.iter().cloned()) + .collect::>(); + if let Some(material) = inventory + .available_materials + .iter() + .find(|material| !required.contains(*material)) + { + return Err(DependencyGraphError::UnusedInventoryMaterial { + material: material.clone(), + }); + } } let names = graph.nodes.keys().cloned().collect::>(); @@ -72,10 +108,17 @@ pub fn resolve_dependency_graph( if roots.is_empty() { roots.extend(names.iter().cloned()); } - let existing = names - .intersection(&inventory.available_artifacts) - .cloned() - .collect::>(); + let mut existing = BTreeSet::new(); + let mut existing_material_lots = BTreeMap::new(); + for artifact in &names { + let availability = artifact_availability(inventory, artifact)?; + if availability.is_available() { + existing.insert(artifact.clone()); + if let Some(binding) = availability.binding() { + existing_material_lots.insert(artifact.clone(), binding.clone()); + } + } + } let unresolved_graph = graph .nodes .iter() @@ -92,6 +135,19 @@ pub fn resolve_dependency_graph( }) .collect::>(); let cyclic = cyclic_nodes(&unresolved_graph); + let required_materials = graph + .nodes + .iter() + .filter(|(artifact, _)| !existing.contains(*artifact)) + .flat_map(|(_, node)| node.required_materials.iter().cloned()) + .collect::>(); + let material_availability = required_materials + .into_iter() + .map(|material| { + let availability = material_availability(inventory, &material)?; + Ok((material, availability)) + }) + .collect::, DependencyGraphError>>()?; let mut available = existing.clone(); let mut pending = names .difference(&available) @@ -127,7 +183,7 @@ pub fn resolve_dependency_graph( let missing_materials = node .required_materials .iter() - .filter(|material| !inventory.available_materials.contains(*material)) + .filter(|material| !material_availability[*material].is_available()) .cloned() .collect::>(); let outcome = if missing_dependencies.is_empty() && missing_materials.is_empty() { @@ -181,16 +237,25 @@ pub fn resolve_dependency_graph( .collect(), node.required_materials .iter() - .filter(|material| !inventory.available_materials.contains(*material)) + .filter(|material| !material_availability[*material].is_available()) .cloned() .collect(), ) }; + let material_lot_bindings = node + .required_materials + .iter() + .filter_map(|material| material_availability.get(material)) + .filter_map(Availability::binding) + .cloned() + .collect(); DependencyNode { artifact: artifact.clone(), dependencies: node.dependencies.iter().cloned().collect(), steps: node.steps.clone(), inventory_materials: node.required_materials.iter().cloned().collect(), + material_lot_bindings, + existing_material_lot: existing_material_lots.get(artifact).cloned(), resolution, generated_in_iteration: generated_at.get(artifact).copied(), missing_dependencies, @@ -211,7 +276,8 @@ pub fn resolve_dependency_graph( ); Ok(DependencyBuildManifest { - schema_version: "lab.dependency-build.v0".into(), + schema_version: "lab.dependency-build.v1".into(), + inventory: inventory_source(inventory), status, roots, nodes, @@ -222,6 +288,89 @@ pub fn resolve_dependency_graph( }) } +fn inventory_source(inventory: &BuildInventory) -> DependencyInventorySource { + match inventory { + BuildInventory::MaterialLots(inventory) => DependencyInventorySource::SbolInventory { + source_sha256: inventory.source_sha256.clone(), + facility: inventory.facility.clone(), + }, + BuildInventory::LegacySymbols(_) => DependencyInventorySource::LegacySymbols, + } +} + +fn artifact_availability( + inventory: &BuildInventory, + artifact: &str, +) -> Result { + match inventory { + BuildInventory::LegacySymbols(inventory) => { + Ok(if inventory.available_artifacts.contains(artifact) { + Availability::Legacy + } else { + Availability::Missing + }) + } + BuildInventory::MaterialLots(inventory) => { + exact_availability(&inventory.artifacts, "artifact", artifact) + } + } +} + +fn material_availability( + inventory: &BuildInventory, + material: &str, +) -> Result { + match inventory { + BuildInventory::LegacySymbols(inventory) => { + Ok(if inventory.available_materials.contains(material) { + Availability::Legacy + } else { + Availability::Missing + }) + } + BuildInventory::MaterialLots(inventory) => { + exact_availability(&inventory.materials, "material", material) + } + } +} + +fn exact_availability( + entries: &BTreeMap, + kind: &'static str, + symbol: &str, +) -> Result { + let Some(candidates) = entries.get(symbol) else { + return Err(DependencyGraphError::MissingDesignIdentity { + kind, + symbol: symbol.to_owned(), + }); + }; + let MaterialLotCandidates::Identified { + component, + material_lots, + } = candidates + else { + return Err(DependencyGraphError::MissingDesignIdentity { + kind, + symbol: symbol.to_owned(), + }); + }; + match material_lots.as_slice() { + [] => Ok(Availability::Missing), + [material_lot] => Ok(Availability::MaterialLot(MaterialLotBinding { + symbol: symbol.to_owned(), + component: component.clone(), + material_lot: material_lot.clone(), + })), + _ => Err(DependencyGraphError::AmbiguousMaterialLot { + kind, + symbol: symbol.to_owned(), + component: component.clone(), + material_lots: material_lots.join(", "), + }), + } +} + fn cyclic_nodes(graph: &BTreeMap>) -> BTreeSet { fn visit( node: &str, @@ -270,10 +419,30 @@ fn cyclic_nodes(graph: &BTreeMap>) -> BTreeSet #[cfg(test)] mod tests { use crate::planning::BuildGraphNode; + use crate::planning::model::{MaterialLotBuildInventory, MaterialLotCandidates}; use crate::planning::resolution::*; + fn identified(component: &str, material_lots: &[&str]) -> MaterialLotCandidates { + MaterialLotCandidates::Identified { + component: component.to_owned(), + material_lots: material_lots.iter().map(|lot| (*lot).to_owned()).collect(), + } + } + + fn semantic_inventory( + materials: BTreeMap, + artifacts: BTreeMap, + ) -> BuildInventory { + BuildInventory::MaterialLots(MaterialLotBuildInventory { + source_sha256: "abc123".to_owned(), + facility: "https://example.org/facility".to_owned(), + materials, + artifacts, + }) + } + #[test] - fn schedules_graph_waves_without_target_knowledge() { + fn schedules_graph_waves_without_facility_knowledge() { let graph = BuildGraph { nodes: BTreeMap::from([ ( @@ -292,12 +461,157 @@ mod tests { ), ]), }; - let inventory = BuildInventory { - available_materials: BTreeSet::from(["source".into()]), - available_artifacts: BTreeSet::new(), - }; + let inventory = BuildInventory::legacy(["source".into()], []); let manifest = resolve_dependency_graph(&graph, &inventory).unwrap(); assert_eq!(manifest.generated_artifacts, ["leaf", "root"]); assert_eq!(manifest.status, DependencyBuildStatus::Complete); } + + #[test] + fn freezes_exact_component_and_material_lot_bindings() { + let graph = BuildGraph { + nodes: BTreeMap::from([( + "product".into(), + BuildGraphNode { + required_materials: BTreeSet::from(["input".into()]), + ..BuildGraphNode::default() + }, + )]), + }; + let inventory = semantic_inventory( + BTreeMap::from([( + "input".into(), + identified( + "https://example.org/component/input", + &["https://example.org/lot/input-7"], + ), + )]), + BTreeMap::from([( + "product".into(), + identified("https://example.org/component/product", &[]), + )]), + ); + + let manifest = resolve_dependency_graph(&graph, &inventory).unwrap(); + + assert_eq!(manifest.schema_version, "lab.dependency-build.v1"); + assert_eq!( + manifest.inventory, + DependencyInventorySource::SbolInventory { + source_sha256: "abc123".to_owned(), + facility: "https://example.org/facility".to_owned(), + } + ); + assert_eq!( + manifest.nodes[0].material_lot_bindings, + [MaterialLotBinding { + symbol: "input".to_owned(), + component: "https://example.org/component/input".to_owned(), + material_lot: "https://example.org/lot/input-7".to_owned(), + }] + ); + assert_eq!(manifest.nodes[0].resolution, ArtifactResolution::Generated); + } + + #[test] + fn refuses_to_allocate_an_ambiguous_material_lot() { + let graph = BuildGraph { + nodes: BTreeMap::from([( + "product".into(), + BuildGraphNode { + required_materials: BTreeSet::from(["input".into()]), + ..BuildGraphNode::default() + }, + )]), + }; + let inventory = semantic_inventory( + BTreeMap::from([( + "input".into(), + identified( + "https://example.org/component/input", + &["https://example.org/lot/a", "https://example.org/lot/b"], + ), + )]), + BTreeMap::from([( + "product".into(), + identified("https://example.org/component/product", &[]), + )]), + ); + + let error = resolve_dependency_graph(&graph, &inventory).unwrap_err(); + + assert_eq!( + error, + DependencyGraphError::AmbiguousMaterialLot { + kind: "material", + symbol: "input".to_owned(), + component: "https://example.org/component/input".to_owned(), + material_lots: "https://example.org/lot/a, https://example.org/lot/b".to_owned(), + } + ); + } + + #[test] + fn exact_inventory_never_falls_back_to_a_symbol_name() { + let graph = BuildGraph { + nodes: BTreeMap::from([( + "product".into(), + BuildGraphNode { + required_materials: BTreeSet::from(["input".into()]), + ..BuildGraphNode::default() + }, + )]), + }; + let inventory = semantic_inventory( + BTreeMap::from([("input".into(), MaterialLotCandidates::Unidentified)]), + BTreeMap::from([( + "product".into(), + identified("https://example.org/component/product", &[]), + )]), + ); + + assert_eq!( + resolve_dependency_graph(&graph, &inventory).unwrap_err(), + DependencyGraphError::MissingDesignIdentity { + kind: "material", + symbol: "input".to_owned(), + } + ); + } + + #[test] + fn an_existing_artifact_binds_its_lot_without_resolving_recipe_leaves() { + let graph = BuildGraph { + nodes: BTreeMap::from([( + "product".into(), + BuildGraphNode { + required_materials: BTreeSet::from(["unavailable_recipe_leaf".into()]), + ..BuildGraphNode::default() + }, + )]), + }; + let inventory = semantic_inventory( + BTreeMap::new(), + BTreeMap::from([( + "product".into(), + identified( + "https://example.org/component/product", + &["https://example.org/lot/product"], + ), + )]), + ); + + let manifest = resolve_dependency_graph(&graph, &inventory).unwrap(); + + assert_eq!(manifest.nodes[0].resolution, ArtifactResolution::Existing); + assert_eq!( + manifest.nodes[0].existing_material_lot, + Some(MaterialLotBinding { + symbol: "product".to_owned(), + component: "https://example.org/component/product".to_owned(), + material_lot: "https://example.org/lot/product".to_owned(), + }) + ); + assert!(manifest.nodes[0].material_lot_bindings.is_empty()); + } } diff --git a/crates/lab-compiler/src/test_support.rs b/crates/lab-compiler/src/test_support.rs index 08b9267..efe112c 100644 --- a/crates/lab-compiler/src/test_support.rs +++ b/crates/lab-compiler/src/test_support.rs @@ -54,8 +54,8 @@ pub fn golden_gate_modules() -> Vec { modules } -/// Those modules lowered together, the way a target build lowers the program -/// a package's entry point forms. +/// Those modules lowered together, the way a package build lowers the program +/// formed by its entry point. pub fn golden_gate_lair() -> PortableLairProgram { let modules = golden_gate_modules(); let borrowed = modules.iter().collect::>(); diff --git a/crates/lab-compiler/tests/fixtures/opentrons-flex-adapter.toml b/crates/lab-compiler/tests/fixtures/opentrons-flex-adapter.toml new file mode 100644 index 0000000..9bd1c5b --- /dev/null +++ b/crates/lab-compiler/tests/fixtures/opentrons-flex-adapter.toml @@ -0,0 +1 @@ +# The explicit `opentrons.flex` driver supplies the reference operational defaults. diff --git a/crates/lab-compiler/tests/lab_opt.rs b/crates/lab-compiler/tests/lab_opt.rs index 9bc7d61..2b613e5 100644 --- a/crates/lab-compiler/tests/lab_opt.rs +++ b/crates/lab-compiler/tests/lab_opt.rs @@ -27,7 +27,7 @@ fn optimizer_verifies_runs_a_named_pipeline_and_prints_ir() { let output = run_with_stdin( &[ "--input-stage", - "target-selected-protocol", + "method-selected-protocol", "--pass-pipeline", "builtin.module(protocol-check-material-linearity)", ], @@ -88,7 +88,7 @@ fn optimizer_reports_non_local_material_linearity_failures() { } #[test] -fn optimizer_lists_its_stable_target_independent_pass_surface() { +fn optimizer_lists_its_stable_facility_independent_pass_surface() { let output = Command::new(env!("CARGO_BIN_EXE_lab-opt")) .arg("--list-passes") .output() @@ -97,7 +97,7 @@ fn optimizer_lists_its_stable_target_independent_pass_surface() { assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); assert!(stdout.contains("protocol-check-material-linearity")); - assert!(stdout.contains("target-selected-protocol -> target-selected-protocol")); + assert!(stdout.contains("method-selected-protocol -> method-selected-protocol")); } #[test] diff --git a/crates/lab-compiler/tests/language_specimens.rs b/crates/lab-compiler/tests/language_specimens.rs index 556c4fb..a5fe6b2 100644 --- a/crates/lab-compiler/tests/language_specimens.rs +++ b/crates/lab-compiler/tests/language_specimens.rs @@ -213,12 +213,12 @@ fn inventory_specimen_preserves_properties_and_resolved_operations() { .iter() .find(|declaration| declaration["kind"] == "catalog" && declaration["name"] == "J23101") .expect("the specimen catalogues its parts"); - assert_eq!(catalogued["identity"], "J23101"); + assert_eq!(catalogued["supplier_identity"], "J23101"); assert_eq!(catalogued["type"]["name"], "Promoter"); let serialized = serde_json::to_string(&module).unwrap(); assert!(serialized.contains("std.bio.build.realize")); - assert!(serialized.contains("artifact_realization")); + assert!(serialized.contains("https://sbol.io/ns/capability#ArtifactRealization")); } #[test] diff --git a/crates/lab-compiler/tests/opentrons_flex_build.rs b/crates/lab-compiler/tests/opentrons_flex_build.rs index 4899cc8..c1c4850 100644 --- a/crates/lab-compiler/tests/opentrons_flex_build.rs +++ b/crates/lab-compiler/tests/opentrons_flex_build.rs @@ -20,10 +20,7 @@ fn fixture(name: &str) -> PathBuf { } fn flex_profile() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../examples/golden-gate/targets/opentrons-flex.toml") - .canonicalize() - .unwrap() + fixture("opentrons-flex-adapter.toml") } fn command_types(protocol: &Value) -> Vec<&str> { @@ -108,7 +105,9 @@ fn writes_a_complete_flex_automation_bundle_of_json_protocols() { fixture("reporter-library.lab").to_str().unwrap(), "--emit", "automation-bundle", - "--target-profile", + "--adapter", + "opentrons.flex", + "--adapter-profile", flex_profile().to_str().unwrap(), "--output-dir", output_dir.to_str().unwrap(), @@ -140,9 +139,9 @@ fn writes_a_complete_flex_automation_bundle_of_json_protocols() { &std::fs::read_to_string(output_dir.join("automation_manifest.json")).unwrap(), ) .unwrap(); - assert_eq!(manifest["target"], "opentrons.flex"); - assert_eq!(manifest["schema_version"], "lab.automation.v0"); - assert_eq!(manifest["deck"]["target"]["backend"], "opentrons.flex"); + assert_eq!(manifest["adapter"], "opentrons.flex"); + assert_eq!(manifest["schema_version"], "lab.automation.v1"); + assert!(manifest["deck"].get("target").is_none()); assert_eq!(manifest["assemblies"].as_array().unwrap().len(), 2); assert_eq!(manifest["strains"].as_array().unwrap().len(), 2); @@ -310,7 +309,9 @@ fn packages_a_dependency_driven_flex_build_by_wave() { fixture("full-build.lab").to_str().unwrap(), "--emit", "full-build-bundle", - "--target-profile", + "--adapter", + "opentrons.flex", + "--adapter-profile", flex_profile().to_str().unwrap(), "--inventory", fixture("full-build-inventory.json").to_str().unwrap(), diff --git a/crates/lab-compiler/tests/plasmid_acceptance.rs b/crates/lab-compiler/tests/plasmid_acceptance.rs index 18eae0d..36c66c0 100644 --- a/crates/lab-compiler/tests/plasmid_acceptance.rs +++ b/crates/lab-compiler/tests/plasmid_acceptance.rs @@ -55,5 +55,5 @@ fn plasmid_acceptance_uses_the_canonical_frontend_boundary() { assert!(human.contains("Lab module compiled")); assert!(human.contains("plasmid p_acceptance")); assert!(human.contains("3 acceptance claims")); - assert!(human.contains("no laboratory target was selected or executed")); + assert!(human.contains("no facility asset was allocated or executed")); } diff --git a/crates/lab-compiler/tests/session.rs b/crates/lab-compiler/tests/session.rs index a947709..073c928 100644 --- a/crates/lab-compiler/tests/session.rs +++ b/crates/lab-compiler/tests/session.rs @@ -15,7 +15,7 @@ fn compiled_ir_round_trips_through_a_fresh_session() { parsed.parse_ir(ir).unwrap(); assert_eq!( parsed.detect_stage().unwrap(), - IrStage::TargetSelectedProtocol + IrStage::MethodSelectedProtocol ); let pipeline = PassPipeline::from_str("builtin.module(protocol-check-material-linearity)").unwrap(); @@ -25,7 +25,7 @@ fn compiled_ir_round_trips_through_a_fresh_session() { let mut reparsed = CompilerSession::default(); reparsed.parse_ir(&reprinted).unwrap(); reparsed - .verify_stage(IrStage::TargetSelectedProtocol) + .verify_stage(IrStage::MethodSelectedProtocol) .unwrap(); } @@ -39,7 +39,7 @@ fn parser_rejects_trailing_input_and_leaves_the_session_reusable() { session.parse_ir(protocol_ir()).unwrap(); session - .verify_stage(IrStage::TargetSelectedProtocol) + .verify_stage(IrStage::MethodSelectedProtocol) .unwrap(); } diff --git a/crates/lab-ide-wasm/src/lib.rs b/crates/lab-ide-wasm/src/lib.rs index 8bd9c1f..13cdd4e 100644 --- a/crates/lab-ide-wasm/src/lib.rs +++ b/crates/lab-ide-wasm/src/lib.rs @@ -1,9 +1,9 @@ //! WebAssembly host API for browser editors and embedded desktop surfaces. use lab_compiler::backend::{ - default_target_profile as compiler_default_target_profile, - target_capabilities as compiler_target_capabilities, - validate_target_profile as compiler_validate_target_profile, + adapter_catalog as compiler_adapter_catalog, + default_adapter_profile as compiler_default_adapter_profile, + validate_adapter_profile as compiler_validate_adapter_profile, }; use lab_ide::Workspace; use lab_language::{ModuleId, SourceId}; @@ -111,28 +111,30 @@ impl Default for LabWorkspace { } } -/// The compiler-owned target catalog used by browser control planes. -#[wasm_bindgen(js_name = targetCapabilities)] -pub fn target_capabilities() -> Result { - serialize( - &compiler_target_capabilities().map_err(|error| JsValue::from_str(&error.to_string()))?, - ) +/// The compiler-owned adapter catalog used by browser control planes. +#[wasm_bindgen(js_name = adapterCatalog)] +pub fn adapter_catalog() -> Result { + serialize(&compiler_adapter_catalog().map_err(|error| JsValue::from_str(&error.to_string()))?) } -/// A complete reference profile for a backend, validated by this compiler. -#[wasm_bindgen(js_name = defaultTargetProfile)] -pub fn default_target_profile(backend: String, name: String) -> Result { +/// A complete reference profile for an explicitly selected adapter. +#[wasm_bindgen(js_name = defaultAdapterProfile)] +pub fn default_adapter_profile(driver: String, name: String) -> Result { serialize( - &compiler_default_target_profile(&backend, &name) + &compiler_default_adapter_profile(&driver, &name) .map_err(|error| JsValue::from_str(&error.to_string()))?, ) } -/// Parse, semantically validate, canonicalize, and hash target TOML. -#[wasm_bindgen(js_name = validateTargetProfile)] -pub fn validate_target_profile(name: String, contents: String) -> Result { +/// Parse, semantically validate, canonicalize, and hash adapter TOML. +#[wasm_bindgen(js_name = validateAdapterProfile)] +pub fn validate_adapter_profile( + driver: String, + name: String, + contents: String, +) -> Result { serialize( - &compiler_validate_target_profile(&name, &contents) + &compiler_validate_adapter_profile(&driver, &name, &contents) .map_err(|error| JsValue::from_str(&error.to_string()))?, ) } diff --git a/crates/lab-instruments/Cargo.toml b/crates/lab-instruments/Cargo.toml index f4ac150..89b6652 100644 --- a/crates/lab-instruments/Cargo.toml +++ b/crates/lab-instruments/Cargo.toml @@ -8,7 +8,7 @@ repository.workspace = true description = "Generic bench-instrument interfaces and adapters over vendor driver crates" [features] -# The Byonoy station's device-opening constructors need the native HID +# The Byonoy adapter's device-opening constructors need the native HID # library; everything else, including the adapter itself, works without it. hid = ["byonoy-hid/hid"] diff --git a/crates/lab-instruments/src/lib.rs b/crates/lab-instruments/src/lib.rs index cbcc767..340d832 100644 --- a/crates/lab-instruments/src/lib.rs +++ b/crates/lab-instruments/src/lib.rs @@ -1,15 +1,15 @@ //! Lab's instrument capabilities: the traits the runtime holds, the -//! neutral vocabulary compiled documents speak, and the station types +//! neutral vocabulary compiled documents speak, and the adapter types //! that implement the traits over vendor driver crates. //! -//! This crate is Lab-internal. Vendor driver crates know nothing of it — -//! they speak their instruments' native types — and each station type +//! This crate is Lab-internal. Vendor driver crates know nothing of it; +//! they speak their instruments' native types, and each adapter type //! here wraps one vendor session and translates between the vendor's -//! vocabulary and the neutral one. The stations are the whole seam: if a +//! vocabulary and the neutral one. The adapters are the whole seam: if a //! vendor someday publishes a good Rust crate of their own, its adapter //! lands here and ours retires. The compiler uses only the data model (a //! `lab.thermocycle-run.v0` document embeds a [`ThermalProfile`]); only -//! the runtime uses the traits and stations. Nothing here may depend on +//! the runtime uses the traits and adapters. Nothing here may depend on //! the rest of the toolchain. //! //! Each trait is one *capability*, not one device category. A device diff --git a/crates/lab-instruments/src/plate_reader/byonoy.rs b/crates/lab-instruments/src/plate_reader/byonoy.rs index 57a957c..af39f67 100644 --- a/crates/lab-instruments/src/plate_reader/byonoy.rs +++ b/crates/lab-instruments/src/plate_reader/byonoy.rs @@ -1,4 +1,4 @@ -//! The Byonoy Absorbance 96 as a workcell plate-reader station. +//! The Byonoy Absorbance 96 adapter for Lab's plate-reader capability. use std::time::Duration; @@ -11,7 +11,7 @@ pub enum ByonoyStationError { #[error(transparent)] Device(#[from] Absorbance96Error), #[error( - "the Absorbance 96 has no luminescence optics; a luminescence read needs a different station" + "the Absorbance 96 has no luminescence optics; a luminescence read needs a different plate reader" )] LuminescenceUnsupported, } diff --git a/crates/lab-instruments/src/plate_reader/mod.rs b/crates/lab-instruments/src/plate_reader/mod.rs index ff4b072..711e04b 100644 --- a/crates/lab-instruments/src/plate_reader/mod.rs +++ b/crates/lab-instruments/src/plate_reader/mod.rs @@ -1,4 +1,4 @@ -//! Plate measurements, the plate-reader interface, and its stations. +//! Plate measurements, the plate-reader interface, and concrete adapters. mod byonoy; @@ -110,7 +110,7 @@ impl PlateData { /// every sensor, so well selection is a reporting concern the caller /// applies to the returned data. Plate access is physical — a reader with /// no drawer relies on whoever (or whatever) carries the plate, which is -/// exactly what a workcell handoff models. +/// represented as an explicit material-movement node in a reviewed facility plan. pub trait PlateReader { type Error: std::error::Error + Send + Sync + 'static; diff --git a/crates/lab-instruments/src/thermocycler/inheco_odtc.rs b/crates/lab-instruments/src/thermocycler/inheco_odtc.rs index f4497ad..c4331a2 100644 --- a/crates/lab-instruments/src/thermocycler/inheco_odtc.rs +++ b/crates/lab-instruments/src/thermocycler/inheco_odtc.rs @@ -1,4 +1,4 @@ -//! The Inheco ODTC as a workcell thermocycler station. +//! The Inheco ODTC adapter for Lab's thermocycler capability. use crate::{ ProfileProgress, RunHandle, SensorReading, ThermalLimits, ThermalProfile, ThermalReadings, @@ -24,7 +24,7 @@ pub fn odtc_thermal_limits() -> ThermalLimits { pub enum OdtcStationError { #[error(transparent)] Device(#[from] OdtcError), - #[error("run handle {handle} names no run this station started")] + #[error("run handle {handle} names no run this adapter started")] UnknownRun { handle: u64 }, } diff --git a/crates/lab-instruments/src/thermocycler/mod.rs b/crates/lab-instruments/src/thermocycler/mod.rs index a083dc9..007f32a 100644 --- a/crates/lab-instruments/src/thermocycler/mod.rs +++ b/crates/lab-instruments/src/thermocycler/mod.rs @@ -1,4 +1,4 @@ -//! Thermal profiles, the thermocycler interface, and its stations. +//! Thermal profiles, the thermocycler interface, and concrete adapters. mod inheco_odtc; diff --git a/crates/lab-inventory/Cargo.toml b/crates/lab-inventory/Cargo.toml new file mode 100644 index 0000000..f2f5fca --- /dev/null +++ b/crates/lab-inventory/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "lab-inventory" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Validated SBOLInventory ingestion for Lab packages." + +[dependencies] +sbol3.workspace = true +sbol-inventory.workspace = true +sha2.workspace = true +thiserror.workspace = true + +[dev-dependencies] +lab-package.workspace = true +tempfile = "3.27.0" + +[lints] +workspace = true diff --git a/crates/lab-inventory/src/lib.rs b/crates/lab-inventory/src/lib.rs new file mode 100644 index 0000000..b734b34 --- /dev/null +++ b/crates/lab-inventory/src/lib.rs @@ -0,0 +1,814 @@ +//! The boundary between portable Lab packages and SBOLInventory facility graphs. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use sbol_inventory::vocabulary::{ControlMode, Qualification}; +use sbol_inventory::{ + CandidateQuery, InventoryDocument, InventoryValidationReport, ScalarValueRef, +}; +use sbol3::{Iri, RdfFormat, ReadError, Resource}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +/// A fully validated inventory document with its package-level facility selection frozen. +#[derive(Clone, Debug)] +pub struct InventorySnapshot { + document: InventoryDocument, + source_path: PathBuf, + source_sha256: String, + facility: Iri, +} + +/// Active material lots in one selected facility, indexed by the exact SBOL Component they realize. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MaterialLotCatalog { + facility: Iri, + by_component: BTreeMap>, +} + +/// Owned planning facts for one exact Asset governed by the selected facility. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FacilityAsset { + pub identity: Iri, + pub located_in: Option, + pub part_of: Option, + pub position: Option, + pub manufacturer: Option, + pub model: Option, + pub offerings: Vec, +} + +/// One exact installed capability offering owned by a [`FacilityAsset`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FacilityCapabilityOffering { + pub identity: Iri, + pub capability_kind: Iri, + pub qualification: Qualification, + pub control_mode: ControlMode, + pub parameters: Vec, + /// True only when both the offering and its complete Asset/Zone containment chain are active. + pub effectively_active: bool, +} + +/// One exact typed parameter owned by a capability offering. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FacilityCapabilityParameter { + pub identity: Iri, + pub property_kind: Iri, + pub value: FacilityScalarValue, + pub unit: Option, +} + +/// The five scalar value forms allowed by SBOLInventory Profile 0.2. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FacilityScalarValue { + Text(String), + Integer(String), + Real(String), + Boolean(bool), + Iri(Iri), +} + +impl MaterialLotCatalog { + pub fn facility(&self) -> &Iri { + &self.facility + } + + /// Returns active lot IRIs in deterministic order for one exact Component IRI. + pub fn candidates(&self, component: &Iri) -> &[Iri] { + self.by_component + .get(component) + .map(Vec::as_slice) + .unwrap_or_default() + } + + pub fn components(&self) -> impl Iterator { + self.by_component + .iter() + .map(|(component, lots)| (component, lots.as_slice())) + } +} + +impl InventorySnapshot { + /// Loads one package-relative inventory document and applies Lab's exact facility-selection rules. + pub fn load( + package_root: impl AsRef, + document_path: impl AsRef, + facility: Option<&str>, + ) -> Result { + let package_root = package_root.as_ref(); + let document_path = document_path.as_ref(); + validate_document_path(document_path)?; + + let canonical_root = fs::canonicalize(package_root).map_err(|source| { + InventoryLoadError::CanonicalizePackageRoot { + path: package_root.to_path_buf(), + source, + } + })?; + let joined_path = canonical_root.join(document_path); + let source_path = + fs::canonicalize(&joined_path).map_err(|source| InventoryLoadError::Read { + path: joined_path.clone(), + source, + })?; + if !source_path.starts_with(&canonical_root) { + return Err(InventoryLoadError::DocumentOutsidePackage { + document: document_path.to_path_buf(), + }); + } + + let format = RdfFormat::from_path(&source_path).ok_or_else(|| { + InventoryLoadError::UnsupportedFormat { + path: document_path.to_path_buf(), + } + })?; + let bytes = fs::read(&source_path).map_err(|source| InventoryLoadError::Read { + path: source_path.clone(), + source, + })?; + let source_sha256 = sha256_hex(&bytes); + let input = String::from_utf8(bytes).map_err(|source| InventoryLoadError::Utf8 { + path: source_path.clone(), + source, + })?; + let document = InventoryDocument::read(&input, format).map_err(|source| { + InventoryLoadError::Parse { + path: source_path.clone(), + source, + } + })?; + if let Err(report) = document.check() { + return Err(InventoryLoadError::InvalidProfile { report }); + } + + let available = facility_iris(&document)?; + let facility = select_facility(facility, &available)?; + Ok(Self { + document, + source_path, + source_sha256, + facility, + }) + } + + pub fn document(&self) -> &InventoryDocument { + &self.document + } + + pub fn source_path(&self) -> &Path { + &self.source_path + } + + pub fn source_sha256(&self) -> &str { + &self.source_sha256 + } + + pub fn facility(&self) -> &Iri { + &self.facility + } + + /// Reconstitutes the query-safe profile view. Construction already proved this succeeds. + pub fn validated(&self) -> sbol_inventory::ValidatedInventory<'_> { + self.document + .check() + .expect("an InventorySnapshot contains a validated immutable document") + } + + /// Indexes only active lots governed by the selected facility. + /// + /// Availability is never inferred from names, display IDs, identity prefixes, or a lot's location. + pub fn active_material_lots(&self) -> Result { + let facility = Resource::Iri(self.facility.clone()); + let mut by_component = BTreeMap::>::new(); + for lot in self + .document + .material_lots() + .filter(|lot| lot.facility_id() == Some(&facility) && lot.is_active() == Some(true)) + { + let lot_identity = lot.identity().as_iri().cloned().ok_or_else(|| { + MaterialLotCatalogError::NonIriMaterialLot { + identity: lot.identity().clone(), + } + })?; + let built = lot + .built_id() + .expect("validated MaterialLots have exactly one sbol:built reference") + .as_iri() + .cloned() + .ok_or_else(|| MaterialLotCatalogError::NonIriBuilt { + material_lot: lot.identity().clone(), + })?; + by_component.entry(built).or_default().push(lot_identity); + } + for lots in by_component.values_mut() { + lots.sort(); + } + Ok(MaterialLotCatalog { + facility: self.facility.clone(), + by_component, + }) + } + + /// Resolves an exact Asset IRI and owns the profile facts facility planning may inspect. + pub fn facility_asset(&self, asset: &str) -> Result { + let identity = + Iri::new(asset.to_owned()).map_err(|error| FacilityAssetError::InvalidAssetIri { + asset: asset.to_owned(), + message: error.to_string(), + })?; + let resource = Resource::Iri(identity.clone()); + let view = + self.document + .asset(&resource) + .ok_or_else(|| FacilityAssetError::AssetNotFound { + asset: identity.clone(), + })?; + let selected_facility = Resource::Iri(self.facility.clone()); + if view.facility_id() != Some(&selected_facility) { + return Err(FacilityAssetError::WrongFacility { + asset: identity, + selected: self.facility.clone(), + actual: view + .facility_id() + .map(ToString::to_string) + .unwrap_or_else(|| "none".to_owned()), + }); + } + + let validated = self.validated(); + let mut offerings = Vec::new(); + for offering in view.capabilities() { + let offering_identity = required_iri( + offering.identity(), + &resource, + FacilityAssetReference::CapabilityOffering, + )?; + let capability_kind = offering + .kind() + .expect("validated offerings have exactly one capability kind") + .clone(); + let qualification = offering + .qualification() + .expect("validated offerings have a known qualification"); + let control_mode = offering + .control_mode() + .expect("validated offerings have a known control mode"); + let mut parameters = Vec::new(); + for parameter in offering.parameters() { + let parameter_identity = + parameter.identity().as_iri().cloned().ok_or_else(|| { + FacilityAssetError::NonIriCapabilityParameter { + offering: offering.identity().clone(), + parameter: parameter.identity().clone(), + } + })?; + let value = match parameter + .value() + .expect("validated PropertyValues have exactly one typed value") + { + ScalarValueRef::Text(value) => FacilityScalarValue::Text(value.to_owned()), + ScalarValueRef::Integer(value) => { + FacilityScalarValue::Integer(value.to_owned()) + } + ScalarValueRef::Real(value) => FacilityScalarValue::Real(value.to_owned()), + ScalarValueRef::Boolean(value) => FacilityScalarValue::Boolean(value), + ScalarValueRef::Iri(value) => FacilityScalarValue::Iri(value.clone()), + }; + parameters.push(FacilityCapabilityParameter { + identity: parameter_identity, + property_kind: parameter + .kind() + .expect("validated PropertyValues have one property kind") + .clone(), + value, + unit: parameter.unit().cloned(), + }); + } + parameters.sort_by(|left, right| left.identity.cmp(&right.identity)); + let query = CandidateQuery::new(capability_kind.clone(), Qualification::Discovered) + .within_facility(self.facility.clone()); + let effectively_active = + validated + .find_qualified_assets(&query) + .iter() + .any(|candidate| { + candidate.asset().identity() == &resource + && candidate.offering().identity() == offering.identity() + }); + offerings.push(FacilityCapabilityOffering { + identity: offering_identity, + capability_kind, + qualification, + control_mode, + parameters, + effectively_active, + }); + } + offerings.sort_by(|left, right| left.identity.cmp(&right.identity)); + + Ok(FacilityAsset { + identity, + located_in: optional_iri( + view.located_in_id(), + &resource, + FacilityAssetReference::Location, + )?, + part_of: optional_iri( + view.part_of_id(), + &resource, + FacilityAssetReference::ParentAsset, + )?, + position: view.position().map(str::to_owned), + manufacturer: view.manufacturer().map(str::to_owned), + model: view.model().map(str::to_owned), + offerings, + }) + } + + /// Owns every Asset governed by the selected facility in deterministic IRI order. + pub fn facility_assets(&self) -> Result, FacilityAssetError> { + let selected = Resource::Iri(self.facility.clone()); + let mut identities = self + .document + .assets() + .filter(|asset| asset.facility_id() == Some(&selected)) + .map(|asset| { + asset.identity().as_iri().cloned().ok_or_else(|| { + FacilityAssetError::NonIriAssetIdentity { + identity: asset.identity().clone(), + } + }) + }) + .collect::, _>>()?; + identities.sort(); + identities + .iter() + .map(|identity| self.facility_asset(identity.as_str())) + .collect() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FacilityAssetReference { + CapabilityOffering, + Location, + ParentAsset, +} + +impl std::fmt::Display for FacilityAssetReference { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::CapabilityOffering => "capability offering", + Self::Location => "location", + Self::ParentAsset => "parent Asset", + }) + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum FacilityAssetError { + #[error("adapter asset `{asset}` is not an absolute IRI: {message}")] + InvalidAssetIri { asset: String, message: String }, + #[error("adapter asset `{asset}` is not a fac:Asset in the inventory document")] + AssetNotFound { asset: Iri }, + #[error("validated fac:Asset `{identity}` does not have an IRI identity")] + NonIriAssetIdentity { identity: Resource }, + #[error( + "adapter asset `{asset}` belongs to facility `{actual}`, not selected facility `{selected}`" + )] + WrongFacility { + asset: Iri, + selected: Iri, + actual: String, + }, + #[error("adapter asset `{asset}` has a non-IRI {reference} `{value}`")] + NonIriReference { + asset: Resource, + reference: FacilityAssetReference, + value: Resource, + }, + #[error("capability offering `{offering}` owns non-IRI parameter `{parameter}`")] + NonIriCapabilityParameter { + offering: Resource, + parameter: Resource, + }, +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum MaterialLotCatalogError { + #[error("validated MaterialLot `{identity}` does not have an IRI identity")] + NonIriMaterialLot { identity: Resource }, + #[error("validated MaterialLot `{material_lot}` has a non-IRI sbol:built reference")] + NonIriBuilt { material_lot: Resource }, +} + +#[derive(Debug, Error)] +pub enum InventoryLoadError { + #[error("inventory document path must be a non-empty package-relative path without '..': {0}")] + InvalidDocumentPath(PathBuf), + #[error("failed to resolve package root `{path}`")] + CanonicalizePackageRoot { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to read inventory document `{path}`")] + Read { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("inventory document `{document}` resolves outside its package")] + DocumentOutsidePackage { document: PathBuf }, + #[error("unsupported inventory RDF format for `{path}`; use .ttl, .rdf, .jsonld, or .nt")] + UnsupportedFormat { path: PathBuf }, + #[error("inventory document `{path}` is not UTF-8")] + Utf8 { + path: PathBuf, + #[source] + source: std::string::FromUtf8Error, + }, + #[error("failed to parse inventory document `{path}`")] + Parse { + path: PathBuf, + #[source] + source: ReadError, + }, + #[error("inventory document does not conform to SBOLInventory Profile 0.2: {report}")] + InvalidProfile { + #[source] + report: InventoryValidationReport, + }, + #[error("facility selector `{facility}` is not an absolute IRI: {message}")] + InvalidFacilityIri { facility: String, message: String }, + #[error("inventory document contains no fac:Facility; add one or select another document")] + NoFacilities, + #[error( + "inventory document contains several facilities ({facilities}); set inventory.facility" + )] + MultipleFacilities { facilities: String }, + #[error( + "facility `{facility}` is not a fac:Facility in the inventory document; available facilities: {available}" + )] + FacilityNotFound { facility: String, available: String }, + #[error("validated facility `{identity}` does not have an IRI identity")] + NonIriFacility { identity: Resource }, +} + +fn validate_document_path(path: &Path) -> Result<(), InventoryLoadError> { + let invalid = path.as_os_str().is_empty() + || path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }); + if invalid { + Err(InventoryLoadError::InvalidDocumentPath(path.to_path_buf())) + } else { + Ok(()) + } +} + +fn facility_iris(document: &InventoryDocument) -> Result, InventoryLoadError> { + let mut facilities = document + .facilities() + .map(|facility| { + facility.identity().as_iri().cloned().ok_or_else(|| { + InventoryLoadError::NonIriFacility { + identity: facility.identity().clone(), + } + }) + }) + .collect::, _>>()?; + facilities.sort(); + Ok(facilities) +} + +fn select_facility(requested: Option<&str>, available: &[Iri]) -> Result { + if let Some(requested) = requested { + let selected = Iri::new(requested.to_owned()).map_err(|error| { + InventoryLoadError::InvalidFacilityIri { + facility: requested.to_owned(), + message: error.to_string(), + } + })?; + if available.contains(&selected) { + return Ok(selected); + } + return Err(InventoryLoadError::FacilityNotFound { + facility: requested.to_owned(), + available: render_facilities(available), + }); + } + + match available { + [] => Err(InventoryLoadError::NoFacilities), + [facility] => Ok(facility.clone()), + facilities => Err(InventoryLoadError::MultipleFacilities { + facilities: render_facilities(facilities), + }), + } +} + +fn render_facilities(facilities: &[Iri]) -> String { + if facilities.is_empty() { + "none".to_owned() + } else { + facilities + .iter() + .map(Iri::as_str) + .collect::>() + .join(", ") + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn optional_iri( + value: Option<&Resource>, + asset: &Resource, + reference: FacilityAssetReference, +) -> Result, FacilityAssetError> { + value + .map(|value| required_iri(value, asset, reference)) + .transpose() +} + +fn required_iri( + value: &Resource, + asset: &Resource, + reference: FacilityAssetReference, +) -> Result { + value + .as_iri() + .cloned() + .ok_or_else(|| FacilityAssetError::NonIriReference { + asset: asset.clone(), + reference, + value: value.clone(), + }) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + + use tempfile::TempDir; + + use super::*; + + const MINIMAL: &str = r#"@prefix cap: . +@prefix ex: . +@prefix fac: . +@prefix sbol: . +@prefix xsd: . + +ex:facility a sbol:TopLevel, fac:Facility ; sbol:displayId "facility" ; + sbol:hasNamespace ; sbol:name "Example facility" . +ex:room a sbol:TopLevel, fac:Zone ; sbol:displayId "room" ; + sbol:hasNamespace ; fac:facility ex:facility ; + fac:zoneKind fac:Room ; fac:isActive true . +ex:cycler a sbol:TopLevel, fac:Asset ; sbol:displayId "cycler" ; + sbol:hasNamespace ; fac:facility ex:facility ; + fac:assetKind fac:Instrument ; fac:locatedIn ex:room ; fac:isActive true ; + fac:capability . + + a sbol:Identified, fac:CapabilityOffering ; sbol:displayId "thermal_cycling" ; + fac:capabilityKind cap:ThermalCycling ; fac:qualification fac:Plannable ; + fac:controlMode fac:ReviewedFileControl ; fac:isActive true ; + fac:parameter . + + a sbol:Identified, fac:PropertyValue ; sbol:displayId "temperature" ; + fac:propertyKind cap:Temperature ; fac:realValue "37.0"^^xsd:double ; + fac:unit . +"#; + + fn write_inventory(root: &Path, contents: &str) { + fs::create_dir(root.join("inventory")).unwrap(); + fs::write(root.join("inventory/catalog.ttl"), contents).unwrap(); + } + + #[test] + fn loads_validates_hashes_and_selects_the_only_facility() { + let package = TempDir::new().unwrap(); + write_inventory(package.path(), MINIMAL); + + let snapshot = + InventorySnapshot::load(package.path(), "inventory/catalog.ttl", None).unwrap(); + + assert_eq!( + snapshot.facility().as_str(), + "https://example.org/sbolinventory/facility" + ); + assert_eq!(snapshot.source_sha256(), sha256_hex(MINIMAL.as_bytes())); + assert_eq!(snapshot.validated().facilities().count(), 1); + assert!(snapshot.source_path().is_absolute()); + } + + #[test] + fn explicit_facility_selection_is_exact() { + let package = TempDir::new().unwrap(); + let several = format!( + "{MINIMAL}\nex:second a sbol:TopLevel, fac:Facility ; sbol:displayId \"second\" ; sbol:hasNamespace .\n" + ); + write_inventory(package.path(), &several); + + let omitted = + InventorySnapshot::load(package.path(), "inventory/catalog.ttl", None).unwrap_err(); + assert!(matches!( + omitted, + InventoryLoadError::MultipleFacilities { .. } + )); + + let selected = InventorySnapshot::load( + package.path(), + "inventory/catalog.ttl", + Some("https://example.org/sbolinventory/second"), + ) + .unwrap(); + assert_eq!( + selected.facility().as_str(), + "https://example.org/sbolinventory/second" + ); + + let missing = InventorySnapshot::load( + package.path(), + "inventory/catalog.ttl", + Some("https://example.org/sbolinventory/missing"), + ) + .unwrap_err(); + assert!(matches!( + missing, + InventoryLoadError::FacilityNotFound { .. } + )); + } + + #[test] + fn rejects_invalid_profiles_and_non_portable_paths() { + let package = TempDir::new().unwrap(); + write_inventory( + package.path(), + &MINIMAL.replace("fac:isActive true", "fac:isActive \"yes\""), + ); + + let invalid = + InventorySnapshot::load(package.path(), "inventory/catalog.ttl", None).unwrap_err(); + assert!(matches!(invalid, InventoryLoadError::InvalidProfile { .. })); + + let escaping = InventorySnapshot::load(package.path(), "../catalog.ttl", None).unwrap_err(); + assert!(matches!( + escaping, + InventoryLoadError::InvalidDocumentPath(_) + )); + } + + #[test] + fn indexes_active_material_lots_by_exact_component_within_the_selected_facility() { + let package = TempDir::new().unwrap(); + let contents = format!( + r#"{MINIMAL} +@prefix inv: . + +ex:design a sbol:Component ; sbol:displayId "design" ; + sbol:hasNamespace ; + sbol:type . +ex:lot_b a sbol:Implementation ; sbol:displayId "lot_b" ; + sbol:hasNamespace ; sbol:built ex:design ; + fac:materialKind inv:DnaSample ; fac:facility ex:facility ; fac:isActive true . +ex:lot_a a sbol:Implementation ; sbol:displayId "lot_a" ; + sbol:hasNamespace ; sbol:built ex:design ; + fac:materialKind inv:DnaSample ; fac:facility ex:facility ; fac:isActive true . +ex:retired_lot a sbol:Implementation ; sbol:displayId "retired_lot" ; + sbol:hasNamespace ; sbol:built ex:design ; + fac:materialKind inv:DnaSample ; fac:facility ex:facility ; fac:isActive false . +"# + ); + write_inventory(package.path(), &contents); + + let snapshot = + InventorySnapshot::load(package.path(), "inventory/catalog.ttl", None).unwrap(); + let catalog = snapshot.active_material_lots().unwrap(); + let design = Iri::new("https://example.org/sbolinventory/design".to_owned()).unwrap(); + let candidates = catalog + .candidates(&design) + .iter() + .map(Iri::as_str) + .collect::>(); + + assert_eq!(catalog.facility(), snapshot.facility()); + assert_eq!( + candidates, + [ + "https://example.org/sbolinventory/lot_a", + "https://example.org/sbolinventory/lot_b", + ] + ); + assert!( + catalog + .components() + .all(|(component, _)| component == &design) + ); + let display_name = Iri::new("https://example.org/design".to_owned()).unwrap(); + assert!(catalog.candidates(&display_name).is_empty()); + } + + #[test] + fn resolves_exact_asset_and_capability_offering_facts() { + let package = TempDir::new().unwrap(); + write_inventory(package.path(), MINIMAL); + let snapshot = + InventorySnapshot::load(package.path(), "inventory/catalog.ttl", None).unwrap(); + + let asset = snapshot + .facility_asset("https://example.org/sbolinventory/cycler") + .unwrap(); + let assets = snapshot.facility_assets().unwrap(); + + assert_eq!(assets.len(), 1); + assert_eq!(assets[0], asset); + assert_eq!( + asset.identity.as_str(), + "https://example.org/sbolinventory/cycler" + ); + assert_eq!( + asset.located_in.as_ref().map(Iri::as_str), + Some("https://example.org/sbolinventory/room") + ); + assert_eq!(asset.offerings.len(), 1); + let offering = &asset.offerings[0]; + assert_eq!( + offering.identity.as_str(), + "https://example.org/sbolinventory/cycler/thermal_cycling" + ); + assert_eq!( + offering.capability_kind.as_str(), + "https://sbol.io/ns/capability#ThermalCycling" + ); + assert_eq!(offering.qualification, Qualification::Plannable); + assert_eq!(offering.control_mode, ControlMode::ReviewedFile); + assert_eq!(offering.parameters.len(), 1); + assert_eq!( + offering.parameters[0].identity.as_str(), + "https://example.org/sbolinventory/cycler/thermal_cycling/temperature" + ); + assert_eq!( + offering.parameters[0].property_kind.as_str(), + "https://sbol.io/ns/capability#Temperature" + ); + assert_eq!( + offering.parameters[0].value, + FacilityScalarValue::Real("37.0".to_owned()) + ); + assert_eq!( + offering.parameters[0].unit.as_ref().map(Iri::as_str), + Some("http://qudt.org/vocab/unit/DEG_C") + ); + assert!(offering.effectively_active); + } + + #[test] + fn exact_asset_resolution_rejects_missing_and_cross_facility_assets() { + let package = TempDir::new().unwrap(); + let several = format!( + "{MINIMAL}\nex:second a sbol:TopLevel, fac:Facility ; sbol:displayId \"second\" ; sbol:hasNamespace .\n" + ); + write_inventory(package.path(), &several); + + let first = InventorySnapshot::load( + package.path(), + "inventory/catalog.ttl", + Some("https://example.org/sbolinventory/facility"), + ) + .unwrap(); + assert!(matches!( + first.facility_asset("not-an-iri"), + Err(FacilityAssetError::InvalidAssetIri { .. }) + )); + assert!(matches!( + first.facility_asset("https://example.org/sbolinventory/missing"), + Err(FacilityAssetError::AssetNotFound { .. }) + )); + + let second = InventorySnapshot::load( + package.path(), + "inventory/catalog.ttl", + Some("https://example.org/sbolinventory/second"), + ) + .unwrap(); + assert!(matches!( + second.facility_asset("https://example.org/sbolinventory/cycler"), + Err(FacilityAssetError::WrongFacility { .. }) + )); + } +} diff --git a/crates/lab-inventory/tests/ebef.rs b/crates/lab-inventory/tests/ebef.rs new file mode 100644 index 0000000..87253ae --- /dev/null +++ b/crates/lab-inventory/tests/ebef.rs @@ -0,0 +1,90 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use lab_inventory::InventorySnapshot; +use lab_package::LabPackage; +use sbol_inventory::CandidateQuery; +use sbol_inventory::vocabulary::{LIQUID_HANDLING, Qualification, THERMAL_CYCLING}; +use sbol3::{Iri, RdfFormat, Resource}; +use tempfile::TempDir; + +const FACILITY: &str = "https://example.org/ebef/facility"; + +fn example_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/ebef") +} + +fn load_example() -> InventorySnapshot { + let package = LabPackage::load(example_root()).unwrap(); + let inventory = &package.manifest.inventory; + InventorySnapshot::load( + &package.root, + inventory.document.as_ref().unwrap(), + inventory.facility.as_deref(), + ) + .unwrap() +} + +#[test] +fn public_ebef_catalog_is_a_valid_described_facility() { + let snapshot = load_example(); + let inventory = snapshot.validated(); + + assert_eq!(snapshot.facility().as_str(), FACILITY); + assert_eq!(inventory.facilities().count(), 1); + assert_eq!(inventory.zones().count(), 12); + assert_eq!(inventory.assets().count(), 28); + assert_eq!(inventory.capability_offerings().count(), 30); + assert_eq!( + snapshot.source_sha256(), + "b965b1ed8ed5a02fdffdde591c1532f3dbec1bb6fc40b022941ef7b0e4f0677a" + ); + + let chamber = inventory + .asset(&Resource::iri( + "https://example.org/ebef/anaerobic_chamber_1", + )) + .unwrap(); + let interior = inventory + .zone(&Resource::iri( + "https://example.org/ebef/anaerobic_chamber_1_interior", + )) + .unwrap(); + let prep = inventory + .asset(&Resource::iri("https://example.org/ebef/microlab_prep")) + .unwrap(); + assert_eq!( + chamber.established_zone_ids().next(), + Some(interior.identity()) + ); + assert_eq!(prep.located_in_id(), Some(interior.identity())); + + let thermal = CandidateQuery::new(Iri::from_static(THERMAL_CYCLING), Qualification::Described) + .within_facility(snapshot.facility().clone()); + assert_eq!(inventory.find_qualified_assets(&thermal).len(), 3); + + let executable_liquid = + CandidateQuery::new(Iri::from_static(LIQUID_HANDLING), Qualification::Executable); + assert!( + inventory + .find_qualified_assets(&executable_liquid) + .is_empty(), + "public equipment descriptions must not become execution claims" + ); +} + +#[test] +fn ebef_graph_round_trips_through_every_supported_rdf_format() { + let snapshot = load_example(); + let output = TempDir::new().unwrap(); + + for &format in RdfFormat::ALL { + let relative = format!("catalog.{}", format.extension()); + let serialized = snapshot.document().write(format).unwrap(); + fs::write(output.path().join(&relative), serialized).unwrap(); + + let reread = InventorySnapshot::load(output.path(), &relative, None).unwrap(); + assert_eq!(reread.facility().as_str(), FACILITY, "{format}"); + assert_eq!(reread.validated().assets().count(), 28, "{format}"); + } +} diff --git a/crates/lab-language/README.md b/crates/lab-language/README.md index b7ef88d..b3e71cd 100644 --- a/crates/lab-language/README.md +++ b/crates/lab-language/README.md @@ -17,6 +17,6 @@ plasmid p_sensor: accept volume >= 20 uL ``` -The module compiler resolves the built-in standard-library modules exercised by the representative specimens and emits structured typed expressions rather than copied source fragments. The [standard-library implementation](src/standard_library/README.md) is an explicit catalog of module specifications; each module owns its exported types, values, pure functions, and durable actions. It checks circuit applications, data constructors, explicit durable workflow state, returns and control flow, timers, and data-driven action contracts with capability and ownership modes. Before returning portable module IR it verifies affine material flow across actions, projections, branches, matches, returns, and reactive handlers. It does not select a laboratory target or dispatch physical actions. +The module compiler resolves the built-in standard-library modules exercised by the representative specimens and emits structured typed expressions rather than copied source fragments. The [standard-library implementation](src/standard_library/README.md) is an explicit catalog of module specifications; each module owns its exported types, values, pure functions, and durable actions. It checks circuit applications, data constructors, explicit durable workflow state, returns and control flow, timers, and data-driven action contracts with capability and ownership modes. Before returning portable module IR it verifies affine material flow across actions, projections, branches, matches, returns, and reactive handlers. It does not allocate facility assets or dispatch physical actions. The evolving language contract, decisions, support matrix, and larger syntax specimens live in [`../../docs/language`](../../docs/language/README.md). diff --git a/crates/lab-language/src/checked.rs b/crates/lab-language/src/checked.rs index 31cadda..4006d06 100644 --- a/crates/lab-language/src/checked.rs +++ b/crates/lab-language/src/checked.rs @@ -15,13 +15,17 @@ use crate::semantics::{DefinitionId, ModuleId, ModuleInterface}; /// declaration of its own and carries the properties its item states, `Data` /// carries no category, a schema field states whether an instance may omit it, /// an acceptance claim carries the evidence it is believed on, a role may name -/// the ontology term it stands for, and an artifact kind carries the roles its -/// produced type plays. +/// the ontology term it stands for, an artifact kind carries the roles its +/// produced type plays, and artifact instances preserve exact SBOL identities +/// independently of laboratory provenance. Action capabilities are absolute +/// SBOLInventory capability-kind IRIs rather than compiler-local names, and +/// durable workflow calls preserve the resolved identity of their callee, and +/// operational parameters preserve absolute SBOLInventory property-kind IRIs. /// -/// A consumer that ignores the last two reads a design with nothing said about -/// what it is, which is exactly the silence grounding exists to end. That is -/// why they raise the version rather than riding along as optional fields. -pub const PORTABLE_MODULE_SCHEMA_VERSION: &str = "lab.portable-module.v4"; +/// Grounding, design identities, and capability identities are semantic +/// contracts, so each incompatible change raises the version rather than +/// riding along as an optional field. +pub const PORTABLE_MODULE_SCHEMA_VERSION: &str = "lab.portable-module.v8"; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CheckedModule { @@ -58,14 +62,19 @@ pub enum CheckedDeclaration { }, /// A name a supplier lists, and the Lab type it stands for. /// - /// The identity is a field rather than an argument to a synthesized call, - /// so a backend reads it directly instead of recognizing a call shape. + /// Biological identity and supplier identity are separate fields rather + /// than arguments to a synthesized call, so consumers cannot confuse an + /// SBOL Component IRI with a catalog order identifier. Catalog { #[serde(default, skip_serializing_if = "Option::is_none")] doc: Option, name: String, r#type: CheckedType, - identity: String, + /// Exact SBOL Component IRI for the biological design, when stated. + #[serde(default, skip_serializing_if = "Option::is_none")] + sbol_identity: Option, + /// Identifier an external catalog or supplier accepts for ordering. + supplier_identity: String, /// What the supplier's item states about itself, checked against the /// fields of the type it is listed as. #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -104,6 +113,9 @@ pub enum CheckedDeclaration { artifact: String, name: String, produces: CheckedType, + /// Exact SBOL Component IRI for the biological design, when stated. + #[serde(default, skip_serializing_if = "Option::is_none")] + sbol_identity: Option, properties: Vec, requirements: Vec, acceptance: Vec, @@ -383,6 +395,12 @@ pub enum OwnershipMode { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ResolvedAction { pub operation: String, + /// Exact declaration identity for a durable workflow call. + /// + /// Standard-library actions have no callee because their `operation` is + /// already the stable semantic operation identity. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub callee: Option, pub capability: Option, pub arguments: Vec, pub results: Vec, @@ -392,6 +410,8 @@ pub struct ResolvedAction { pub struct CheckedActionArgument { pub name: String, pub mode: OwnershipMode, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parameter_kind: Option, pub value: TypedExpression, } diff --git a/crates/lab-language/src/checker.rs b/crates/lab-language/src/checker.rs index 2a9b82c..b5e871c 100644 --- a/crates/lab-language/src/checker.rs +++ b/crates/lab-language/src/checker.rs @@ -1681,13 +1681,12 @@ workflow read_out( ); } - /// What an order names is the declared name, unless the item states an - /// identity of its own, because in practice the two are almost always the - /// same. + /// What an order names is the declared name unless the item states a + /// supplier identity. This is unrelated to its SBOL Component IRI. #[test] - fn a_bought_item_defaults_its_identity_to_its_name() { + fn a_bought_item_defaults_its_supplier_identity_to_its_name() { let module = compile_module( - "use std.bio.designs\nuse std.bio.golden_gate\n\nbuy part J23101\nbuy part GFP\nbuy restriction_enzyme BsaI_HF:\n identity = \"BsaI-HF-v2\"\n", + "use std.bio.designs\nuse std.bio.golden_gate\n\nbuy part J23101\nbuy part GFP\nbuy restriction_enzyme BsaI_HF:\n supplier_identity = \"BsaI-HF-v2\"\n", ) .expect("each bought item names its kind"); @@ -1697,10 +1696,14 @@ workflow read_out( .filter_map(|declaration| match declaration { CheckedDeclaration::Catalog { name, - identity, + supplier_identity, r#type, .. - } => Some((name.as_str(), identity.as_str(), r#type.display_name())), + } => Some(( + name.as_str(), + supplier_identity.as_str(), + r#type.display_name(), + )), _ => None, }) .collect::>(); @@ -1712,10 +1715,75 @@ workflow read_out( ("GFP", "GFP", "Part".to_owned()), ("BsaI_HF", "BsaI-HF-v2", "RestrictionEnzyme".to_owned()), ], - "each name is its own declaration, and an identity is written only where it differs" + "each name is its own declaration, and an order identifier is written only where it differs" ); } + #[test] + fn legacy_identity_remains_a_supplier_identity_alias() { + let module = + compile_module("use std.bio.designs\n\nbuy part legacy:\n identity = \"SKU-17\"\n") + .unwrap(); + let CheckedDeclaration::Catalog { + sbol_identity, + supplier_identity, + properties, + .. + } = &module.declarations[0] + else { + panic!("the declaration is bought"); + }; + assert_eq!(sbol_identity, &None); + assert_eq!(supplier_identity, "SKU-17"); + assert!(properties.is_empty()); + } + + #[test] + fn sbol_identity_is_preserved_independently_of_build_or_buy() { + let module = compile_module( + r#"use std.bio.designs + +build part local_design: + sbol_identity = "https://example.org/design/local" + +buy part catalogued_design: + sbol_identity = "https://example.org/design/catalogued" + supplier_identity = "SKU-42" +"#, + ) + .unwrap(); + + let CheckedDeclaration::Artifact { sbol_identity, .. } = &module.declarations[0] else { + panic!("the first declaration is built"); + }; + assert_eq!( + sbol_identity.as_deref(), + Some("https://example.org/design/local") + ); + let CheckedDeclaration::Catalog { + sbol_identity, + supplier_identity, + .. + } = &module.declarations[1] + else { + panic!("the second declaration is bought"); + }; + assert_eq!( + sbol_identity.as_deref(), + Some("https://example.org/design/catalogued") + ); + assert_eq!(supplier_identity, "SKU-42"); + } + + #[test] + fn sbol_identity_must_be_an_absolute_iri() { + let error = compile_module( + "use std.bio.designs\n\nbuy part local:\n sbol_identity = \"BBa_J23101\"\n", + ) + .unwrap_err(); + assert!(error.to_string().contains("not an absolute IRI"), "{error}"); + } + /// A parameterized type is catalogued when its head is, which is a separate /// question from whether the whole type packs into `any Role`. #[test] @@ -1903,7 +1971,10 @@ workflow preserve(plasmid: Material) -> Material: panic!("expected effect") }; assert_eq!(action.operation, "std.lab.plasmid.store"); - assert_eq!(action.capability.as_deref(), Some("cold_storage")); + assert_eq!( + action.capability.as_deref(), + Some("https://sbol.io/ns/capability#ColdStorage") + ); assert_eq!(action.arguments[0].mode, OwnershipMode::Take); assert_eq!(action.results[0].name, "material"); assert_eq!(action.results[0].r#type.display_name(), "Material"); diff --git a/crates/lab-language/src/checker/action_contract.rs b/crates/lab-language/src/checker/action_contract.rs index b654e48..f3a0701 100644 --- a/crates/lab-language/src/checker/action_contract.rs +++ b/crates/lab-language/src/checker/action_contract.rs @@ -58,6 +58,7 @@ impl Checker { arguments.push(CheckedActionArgument { name: (*name).to_owned(), mode: *mode, + parameter_kind: None, value: TypedExpression { r#type: to_checked_type(&ty), value: CheckedExpression::List { @@ -163,11 +164,16 @@ impl Checker { arguments.push(CheckedActionArgument { name: (*name).to_owned(), mode: *mode, + parameter_kind: None, value: action_reference(self.definition_for_action_word(word), word, &actual), }); *cursor += 1; } - PhrasePart::Integer { name, signed } => { + PhrasePart::Integer { + name, + property_kind, + signed, + } => { let word = words.get(*cursor).ok_or_else(|| { SemanticError::new( effect.span, @@ -181,12 +187,14 @@ impl Checker { arguments.push(CheckedActionArgument { name: (*name).to_owned(), mode: OwnershipMode::Copy, + parameter_kind: Some((*property_kind).to_owned()), value, }); *cursor += 1; } PhrasePart::Quantity { name, + property_kind, signed, units, } => { @@ -221,6 +229,7 @@ impl Checker { arguments.push(CheckedActionArgument { name: (*name).to_owned(), mode: OwnershipMode::Copy, + parameter_kind: Some((*property_kind).to_owned()), value: TypedExpression { r#type: CheckedType::Quantity { unit: (*unit).to_owned(), @@ -262,6 +271,7 @@ impl Checker { Ok(( ResolvedAction { operation: contract.operation.to_owned(), + callee: None, capability: Some(contract.capability.to_owned()), arguments, results: checked_results, diff --git a/crates/lab-language/src/checker/declarations.rs b/crates/lab-language/src/checker/declarations.rs index f5e766d..33cf595 100644 --- a/crates/lab-language/src/checker/declarations.rs +++ b/crates/lab-language/src/checker/declarations.rs @@ -12,6 +12,7 @@ use crate::checked::{ CheckedAcceptance, CheckedCase, CheckedDeclaration, CheckedPresence, CheckedProperty, CheckedSection, }; +use crate::iri::is_absolute_iri; use crate::semantic_error::SemanticError; use crate::source::{Identifier, Span}; use crate::type_system::{Ty, to_checked_type}; @@ -914,6 +915,8 @@ impl Checker { } let mut properties = Vec::new(); let mut property_names = BTreeSet::new(); + let mut sbol_identity = None; + let mut supplier_identity = None; let mut requirements = Vec::new(); let mut acceptance = Vec::new(); // The declaration's standard is read first so a claim written above it @@ -946,15 +949,52 @@ impl Checker { format!("duplicate {keyword} property '{}'", property.name.value), )); } + let is_sbol_identity = property.name.value == "sbol_identity"; + let is_supplier_identity = declaration.provenance == Provenance::Buy + && matches!( + property.name.value.as_str(), + "identity" | "supplier_identity" + ); + if is_sbol_identity || is_supplier_identity { + let Expr::String { value, .. } = &property.value else { + return Err(SemanticError::new( + property.value.span(), + format!( + "{} is a String literal", + if is_sbol_identity { + "an SBOL identity" + } else { + "a supplier identity" + } + ), + )); + }; + if is_sbol_identity { + if !is_absolute_iri(value) { + return Err(SemanticError::new( + property.value.span(), + format!("SBOL identity '{value}' is not an absolute IRI"), + ) + .help("use an absolute IRI such as 'https://example.org/design'")); + } + sbol_identity = Some(value.clone()); + } else { + if supplier_identity.is_some() { + return Err(SemanticError::new( + property.name.span, + "a bought item states its supplier identity twice", + ) + .help("use 'supplier_identity'; 'identity' is its legacy alias")); + } + supplier_identity = Some(value.clone()); + } + continue; + } // A schema says what a thing may state, so a name it does - // not declare is a mistake rather than an extension. Every - // bought thing may name what an order asks for, so - // `identity` belongs to buying rather than to any one - // kind's schema. - if !(declaration.provenance == Provenance::Buy - && property.name.value == "identity") - && !signature.fields.contains_key(&property.name.value) - { + // not declare is a mistake rather than an extension. SBOL + // and supplier identities were consumed above because + // they describe the instance, not one artifact kind. + if !signature.fields.contains_key(&property.name.value) { let mut error = SemanticError::new( property.name.span, format!("{produces} has no property '{}'", property.name.value), @@ -968,16 +1008,6 @@ impl Checker { return Err(error); } let inferred = self.infer_expr(&property.value, &environment)?; - if declaration.provenance == Provenance::Buy - && property.name.value == "identity" - && inferred != Ty::String - { - return Err(SemanticError::new( - property.value.span(), - format!("an identity is a String, found {inferred}"), - ) - .help("an identity is what a supplier's catalogue calls this item")); - } if let Some(expected) = environment.get(&property.name.value) && !self.compatible(&inferred, expected) { @@ -1072,21 +1102,15 @@ impl Checker { } if declaration.provenance == Provenance::Buy { // A supplier lists it, so it is never built and there is nothing to - // accept it against. The identity is what an order names, which is - // the declared name unless the item states otherwise. - let identity = properties - .iter() - .find(|property| property.name == "identity") - .and_then(|property| match &property.value.value { - crate::checked::CheckedExpression::String { value } => Some(value.clone()), - _ => None, - }) - .unwrap_or_else(|| declaration.name.value.clone()); + // accept it against. Its order identifier defaults to the declared + // name and stays independent of the biological design IRI. return Ok(CheckedDeclaration::Catalog { doc: declaration.doc.clone(), name: declaration.name.value.clone(), r#type: to_checked_type(&produces), - identity, + sbol_identity, + supplier_identity: supplier_identity + .unwrap_or_else(|| declaration.name.value.clone()), properties, }); } @@ -1095,6 +1119,7 @@ impl Checker { artifact: keyword.to_owned(), name: declaration.name.value.clone(), produces: to_checked_type(&produces), + sbol_identity, properties, requirements, acceptance, diff --git a/crates/lab-language/src/checker/workflow.rs b/crates/lab-language/src/checker/workflow.rs index 2002989..206ccd0 100644 --- a/crates/lab-language/src/checker/workflow.rs +++ b/crates/lab-language/src/checker/workflow.rs @@ -520,6 +520,7 @@ impl Checker { } else { OwnershipMode::Copy }, + parameter_kind: None, value: action_contract::action_reference( self.definition_for_action_word(word), word, @@ -530,6 +531,7 @@ impl Checker { Ok(( ResolvedAction { operation: format!("workflow.{operation}"), + callee: Some(self.definition_for_action_word(operation)), capability: None, arguments, results: outputs diff --git a/crates/lab-language/src/iri.rs b/crates/lab-language/src/iri.rs new file mode 100644 index 0000000..491f33b --- /dev/null +++ b/crates/lab-language/src/iri.rs @@ -0,0 +1,37 @@ +/// Recognizes an absolute IRI without pulling an RDF or URL stack into the language frontend. +pub fn is_absolute_iri(value: &str) -> bool { + let Some((scheme, rest)) = value.split_once(':') else { + return false; + }; + !rest.is_empty() + && scheme + .chars() + .next() + .is_some_and(|character| character.is_ascii_alphabetic()) + && scheme.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.') + }) + && !value.chars().any(|character| { + character.is_whitespace() + || character.is_control() + || matches!( + character, + '<' | '>' | '"' | '{' | '}' | '|' | '\\' | '^' | '`' + ) + }) +} + +#[cfg(test)] +mod tests { + use super::is_absolute_iri; + + #[test] + fn recognizes_absolute_iris_without_importing_an_rdf_stack() { + assert!(is_absolute_iri("https://example.org/design")); + assert!(is_absolute_iri( + "urn:uuid:2ed8c319-58b7-46ad-aaf0-95c79be6b107" + )); + assert!(!is_absolute_iri("BBa_J23101")); + assert!(!is_absolute_iri("https://example.org/a design")); + } +} diff --git a/crates/lab-language/src/lib.rs b/crates/lab-language/src/lib.rs index 3b8488e..9af8354 100644 --- a/crates/lab-language/src/lib.rs +++ b/crates/lab-language/src/lib.rs @@ -5,6 +5,7 @@ mod checked; mod checker; mod diagnostics; mod error; +mod iri; mod lexer; mod material_flow; mod parser; @@ -29,6 +30,7 @@ pub use diagnostics::{ SourceId, analyze_module, analyze_module_in_environment, render_diagnostic, }; pub use error::ParseError; +pub use iri::is_absolute_iri; pub use material_flow::MaterialFlowError; pub use parser::parse_module; pub use render::render_checked_module; diff --git a/crates/lab-language/src/provenance.rs b/crates/lab-language/src/provenance.rs index 85d7608..b6106c6 100644 --- a/crates/lab-language/src/provenance.rs +++ b/crates/lab-language/src/provenance.rs @@ -106,8 +106,8 @@ type LineageTable = BTreeMap>; /// What every workflow in a module knows about where its materials came from, /// keyed by workflow name. /// -/// A target reads this to decide what it may pool, and the language server -/// reads it to explain a sample's history where it is written. +/// Method selection and adapter planning read this to decide what may be +/// pooled, and the language server reads it to explain a sample's history. pub fn lineage(module: &CheckedModule) -> BTreeMap { let table = lineage_table(&StandardLibrary::bundled()); module diff --git a/crates/lab-language/src/render.rs b/crates/lab-language/src/render.rs index 4fb5383..eefdda4 100644 --- a/crates/lab-language/src/render.rs +++ b/crates/lab-language/src/render.rs @@ -1,7 +1,7 @@ use crate::{CheckedDeclaration, CheckedModule}; /// Render the verified portable module boundary without implying physical -/// execution or target selection. +/// execution or facility allocation. pub fn render_checked_module(module: &CheckedModule) -> String { let mut output = String::from("Lab module compiled\n\n"); if !module.imports.is_empty() { @@ -38,10 +38,10 @@ pub fn render_checked_module(module: &CheckedModule) -> String { CheckedDeclaration::Catalog { name, r#type, - identity, + supplier_identity, .. } => output.push_str(&format!( - " - catalog {name}: {type} (\"{identity}\")\n", + " - catalog {name}: {type} (supplier \"{supplier_identity}\")\n", r#type = r#type )), CheckedDeclaration::Data { name, roles, .. } => { @@ -83,7 +83,7 @@ pub fn render_checked_module(module: &CheckedModule) -> String { } } output.push_str( - "\nThis is verified portable module IR; no laboratory target was selected or executed.\n", + "\nThis is verified portable module IR; no facility asset was allocated or executed.\n", ); output } diff --git a/crates/lab-language/src/standard_library/README.md b/crates/lab-language/src/standard_library/README.md index bc88664..ff56d08 100644 --- a/crates/lab-language/src/standard_library/README.md +++ b/crates/lab-language/src/standard_library/README.md @@ -8,6 +8,7 @@ checker special cases. - `catalog.rs` defines immutable standard modules, their export kinds, catalog validation, and lookup. +- `capability.rs` defines the absolute SBOLInventory capability-kind IRIs used by bundled durable actions. - `contract.rs` defines the typed phrase, ownership, capability, and result contract for durable actions. - `prelude.rs` contains the explicitly identified implicit prelude. Names in diff --git a/crates/lab-language/src/standard_library/authored/designs.lab b/crates/lab-language/src/standard_library/authored/designs.lab index 61eb1a0..dc39a20 100644 --- a/crates/lab-language/src/standard_library/authored/designs.lab +++ b/crates/lab-language/src/standard_library/authored/designs.lab @@ -7,8 +7,8 @@ * declaration that names it, not by its kind. * * Each kind states the ontology terms it stands for, so what it is travels with - * it. A target reading a design knows a backbone is DNA and an antibiotic is a - * small molecule without being told separately. + * it. Any consumer reading a design knows a backbone is DNA and an antibiotic + * is a small molecule without being told separately. */ use std.bio.ontology diff --git a/crates/lab-language/src/standard_library/bio/build.rs b/crates/lab-language/src/standard_library/bio/build.rs index cd86ee6..6bc1e69 100644 --- a/crates/lab-language/src/standard_library/bio/build.rs +++ b/crates/lab-language/src/standard_library/bio/build.rs @@ -1,6 +1,7 @@ //! Artifact-realization operations in `std.bio.build`. use crate::checked::OwnershipMode; +use crate::standard_library::capability; use crate::standard_library::catalog::StandardModule; use crate::standard_library::contract::{ ActionContractSpec, ContractType, Lineage, PhrasePart, ResultSpec, @@ -13,7 +14,7 @@ pub(in crate::standard_library::bio) fn module() -> StandardModule { let concrete = ContractType::Concrete; let action = ActionContractSpec { operation: "std.bio.build.realize", - capability: "artifact_realization", + capability: capability::ARTIFACT_REALIZATION, phrase: vec![ PhrasePart::Word("realize"), PhrasePart::Operand { diff --git a/crates/lab-language/src/standard_library/capability.rs b/crates/lab-language/src/standard_library/capability.rs new file mode 100644 index 0000000..9759d17 --- /dev/null +++ b/crates/lab-language/src/standard_library/capability.rs @@ -0,0 +1,23 @@ +//! Absolute capability-kind IRIs used by bundled durable action contracts. +//! +//! SBOLInventory capability kinds are an open vocabulary. Constants already present in Profile +//! 0.2 use its normative names; the remaining operation-specific terms use the same namespace and +//! are explicit extension terms pending inclusion in a future vocabulary snapshot. + +pub(crate) const ARTIFACT_REALIZATION: &str = "https://sbol.io/ns/capability#ArtifactRealization"; +pub(crate) const PLATE_IMAGING: &str = "https://sbol.io/ns/capability#PlateImaging"; +pub(crate) const DNA_SYNTHESIS: &str = "https://sbol.io/ns/capability#DnaSynthesis"; +pub(crate) const DNA_ASSEMBLY: &str = "https://sbol.io/ns/capability#DnaAssembly"; +pub(crate) const MATERIAL_PROVISIONING: &str = "https://sbol.io/ns/capability#MaterialProvisioning"; +pub(crate) const CHEMICAL_TRANSFORMATION: &str = + "https://sbol.io/ns/capability#ChemicalTransformation"; +pub(crate) const INCUBATION: &str = "https://sbol.io/ns/capability#Incubation"; +pub(crate) const LIQUID_HANDLING: &str = "https://sbol.io/ns/capability#LiquidHandling"; +pub(crate) const ANTIBIOTIC_SELECTION: &str = "https://sbol.io/ns/capability#AntibioticSelection"; +pub(crate) const COLONY_PICKING: &str = "https://sbol.io/ns/capability#ColonyPicking"; +pub(crate) const CLONE_SCREENING: &str = "https://sbol.io/ns/capability#CloneScreening"; +pub(crate) const PLASMID_PURIFICATION: &str = "https://sbol.io/ns/capability#PlasmidPurification"; +pub(crate) const SANGER_SEQUENCING: &str = "https://sbol.io/ns/capability#SangerSequencing"; +pub(crate) const DNA_QUANTIFICATION: &str = "https://sbol.io/ns/capability#DnaQuantification"; +pub(crate) const COLD_STORAGE: &str = "https://sbol.io/ns/capability#ColdStorage"; +pub(crate) const WASTE_HANDLING: &str = "https://sbol.io/ns/capability#WasteHandling"; diff --git a/crates/lab-language/src/standard_library/catalog.rs b/crates/lab-language/src/standard_library/catalog.rs index 25bea0d..ea7e350 100644 --- a/crates/lab-language/src/standard_library/catalog.rs +++ b/crates/lab-language/src/standard_library/catalog.rs @@ -710,7 +710,7 @@ mod tests { fn rejects_malformed_action_contracts_during_registration() { let malformed = ActionContractSpec { operation: "std.test.broken", - capability: "testing", + capability: "https://example.org/capability#Testing", phrase: vec![PhrasePart::Operand { name: "input", r#type: ContractType::Concrete(Ty::String), diff --git a/crates/lab-language/src/standard_library/contract.rs b/crates/lab-language/src/standard_library/contract.rs index ebf6ad1..e17b1a4 100644 --- a/crates/lab-language/src/standard_library/contract.rs +++ b/crates/lab-language/src/standard_library/contract.rs @@ -3,6 +3,7 @@ use std::collections::BTreeSet; use crate::checked::OwnershipMode; +use crate::iri::is_absolute_iri; use crate::type_system::Ty; #[derive(Clone, Debug, PartialEq, Eq)] @@ -28,10 +29,12 @@ pub(crate) enum PhrasePart { }, Integer { name: &'static str, + property_kind: &'static str, signed: bool, }, Quantity { name: &'static str, + property_kind: &'static str, signed: bool, units: &'static [&'static str], }, @@ -106,14 +109,17 @@ impl ActionContractSpec { if self.operation.is_empty() { return Err("action operation identity cannot be empty".to_owned()); } - if self.capability.is_empty() { - return Err("action capability cannot be empty".to_owned()); + if !is_absolute_iri(self.capability) { + return Err(format!( + "action capability '{}' is not an absolute IRI", + self.capability + )); } let mut argument_names = BTreeSet::new(); let mut operands = BTreeSet::new(); for part in self.phrase.iter().flat_map(PhrasePart::parts) { - let (name, units) = match part { + let (name, property_kind, units) = match part { PhrasePart::Word(word) => { if word.is_empty() { return Err("action phrase words cannot be empty".to_owned()); @@ -129,10 +135,19 @@ impl ActionContractSpec { )); } operands.insert(*name); - (*name, None) + (*name, None, None) } - PhrasePart::Integer { name, .. } => (*name, None), - PhrasePart::Quantity { name, units, .. } => (*name, Some(*units)), + PhrasePart::Integer { + name, + property_kind, + .. + } => (*name, Some(*property_kind), None), + PhrasePart::Quantity { + name, + property_kind, + units, + .. + } => (*name, Some(*property_kind), Some(*units)), PhrasePart::Optional(_) => { return Err("an optional clause cannot nest another".to_owned()); } @@ -142,6 +157,11 @@ impl ActionContractSpec { "action argument '{name}' is declared more than once" )); } + if property_kind.is_some_and(|kind| !is_absolute_iri(kind)) { + return Err(format!( + "action parameter '{name}' property kind must be an absolute IRI" + )); + } if units.is_some_and(<[_]>::is_empty) { return Err(format!( "quantity argument '{name}' must allow at least one unit" @@ -210,12 +230,43 @@ mod tests { fn contract(phrase: Vec) -> ActionContractSpec { ActionContractSpec { operation: "test.action", - capability: "testing", + capability: "https://example.org/capability#Testing", phrase, results: Vec::new(), } } + #[test] + fn a_capability_must_be_an_absolute_iri() { + let mut action = contract(vec![PhrasePart::Word("act")]); + action.capability = "testing"; + + let error = action.validate().expect_err("bare names are not portable"); + + assert!(error.contains("not an absolute IRI"), "{error}"); + } + + #[test] + fn a_parameter_kind_must_be_an_absolute_iri() { + let action = contract(vec![ + PhrasePart::Word("act"), + PhrasePart::Integer { + name: "count", + property_kind: "count", + signed: false, + }, + ]); + + let error = action + .validate() + .expect_err("argument names are not RDF property identities"); + + assert!( + error.contains("property kind must be an absolute IRI"), + "{error}" + ); + } + fn optional_operand(r#type: ContractType) -> PhrasePart { PhrasePart::Optional(vec![ PhrasePart::Word("from"), diff --git a/crates/lab-language/src/standard_library/lab/plasmid.rs b/crates/lab-language/src/standard_library/lab/plasmid.rs index 169fee4..d56d83a 100644 --- a/crates/lab-language/src/standard_library/lab/plasmid.rs +++ b/crates/lab-language/src/standard_library/lab/plasmid.rs @@ -5,6 +5,7 @@ use crate::standard_library::catalog::StandardModule; use crate::standard_library::contract::{ ActionContractSpec, ContractType, Lineage, PhrasePart, ResultSpec, }; +use crate::standard_library::{capability, parameter}; use crate::type_system::Ty; pub(in crate::standard_library::lab) fn module() -> StandardModule { @@ -32,7 +33,7 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { let actions = vec![ ActionContractSpec { operation: "std.lab.plasmid.capture", - capability: "plate_imaging", + capability: capability::PLATE_IMAGING, phrase: vec![ PhrasePart::Word("capture"), PhrasePart::Word("image"), @@ -43,7 +44,7 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { }, ActionContractSpec { operation: "std.lab.plasmid.synthesize", - capability: "dna_synthesis", + capability: capability::DNA_SYNTHESIS, phrase: vec![ PhrasePart::Word("synthesize"), operand("design", concrete(named("Plasmid")), copy), @@ -55,7 +56,7 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { }, ActionContractSpec { operation: "std.lab.plasmid.assemble", - capability: "dna_assembly", + capability: capability::DNA_ASSEMBLY, phrase: vec![ PhrasePart::Word("assemble"), operand( @@ -68,7 +69,7 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { }, ActionContractSpec { operation: "std.lab.plasmid.provision", - capability: "inventory", + capability: capability::MATERIAL_PROVISIONING, phrase: vec![ PhrasePart::Word("provision"), operand("item", ContractType::AnyValue, copy), @@ -80,7 +81,7 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { }, ActionContractSpec { operation: "std.lab.plasmid.transform", - capability: "chemical_transformation", + capability: capability::CHEMICAL_TRANSFORMATION, phrase: vec![ PhrasePart::Word("transform"), operand("design", concrete(named("Strain")), copy), @@ -100,13 +101,14 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { }, ActionContractSpec { operation: "std.lab.plasmid.recover", - capability: "culture_incubation", + capability: capability::INCUBATION, phrase: vec![ PhrasePart::Word("recover"), operand("culture", concrete(material(named("Culture"))), take), PhrasePart::Word("for"), PhrasePart::Quantity { name: "duration", + property_kind: parameter::DURATION, signed: false, units: &["min", "h"], }, @@ -115,7 +117,7 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { }, ActionContractSpec { operation: "std.lab.plasmid.dilute", - capability: "liquid_handling", + capability: capability::LIQUID_HANDLING, phrase: vec![ PhrasePart::Word("dilute"), operand("culture", concrete(material(named("Culture"))), take), @@ -124,7 +126,7 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { }, ActionContractSpec { operation: "std.lab.plasmid.plate", - capability: "antibiotic_selection", + capability: capability::ANTIBIOTIC_SELECTION, phrase: vec![ PhrasePart::Word("plate"), operand("culture", concrete(material(named("Culture"))), take), @@ -135,11 +137,12 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { }, ActionContractSpec { operation: "std.lab.plasmid.pick", - capability: "colony_picking", + capability: capability::COLONY_PICKING, phrase: vec![ PhrasePart::Word("pick"), PhrasePart::Integer { name: "count", + property_kind: parameter::COUNT, signed: false, }, PhrasePart::Word("isolated"), @@ -154,7 +157,7 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { }, ActionContractSpec { operation: "std.lab.plasmid.screen", - capability: "clone_screening", + capability: capability::CLONE_SCREENING, phrase: vec![ PhrasePart::Word("screen"), operand( @@ -169,19 +172,21 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { }, ActionContractSpec { operation: "std.lab.plasmid.grow", - capability: "culture_incubation", + capability: capability::INCUBATION, phrase: vec![ PhrasePart::Word("grow"), operand("clone", concrete(material(named("Clone"))), take), PhrasePart::Word("at"), PhrasePart::Quantity { name: "temperature", + property_kind: parameter::TEMPERATURE, signed: true, units: &["C"], }, PhrasePart::Word("for"), PhrasePart::Quantity { name: "duration", + property_kind: parameter::DURATION, signed: false, units: &["h"], }, @@ -190,7 +195,7 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { }, ActionContractSpec { operation: "std.lab.plasmid.purify", - capability: "plasmid_purification", + capability: capability::PLASMID_PURIFICATION, phrase: vec![ PhrasePart::Word("purify"), operand("culture", concrete(material(named("Culture"))), take), @@ -199,7 +204,7 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { }, ActionContractSpec { operation: "std.lab.plasmid.split", - capability: "liquid_handling", + capability: capability::LIQUID_HANDLING, phrase: vec![ PhrasePart::Word("split"), operand("material", concrete(material(named("Plasmid"))), take), @@ -211,7 +216,7 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { }, ActionContractSpec { operation: "std.lab.plasmid.sequence", - capability: "sanger_sequencing", + capability: capability::SANGER_SEQUENCING, phrase: vec![ PhrasePart::Word("sequence"), operand("aliquot", concrete(material(named("Plasmid"))), take), @@ -220,7 +225,7 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { }, ActionContractSpec { operation: "std.lab.plasmid.quantify", - capability: "dna_quantification", + capability: capability::DNA_QUANTIFICATION, phrase: vec![ PhrasePart::Word("quantify"), operand("material", concrete(material(named("Plasmid"))), borrow), @@ -229,13 +234,14 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { }, ActionContractSpec { operation: "std.lab.plasmid.store", - capability: "cold_storage", + capability: capability::COLD_STORAGE, phrase: vec![ PhrasePart::Word("store"), operand("material", concrete(material(named("Plasmid"))), take), PhrasePart::Word("at"), PhrasePart::Quantity { name: "temperature", + property_kind: parameter::TEMPERATURE, signed: true, units: &["C"], }, @@ -244,7 +250,7 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { }, ActionContractSpec { operation: "std.lab.plasmid.dispose", - capability: "waste_handling", + capability: capability::WASTE_HANDLING, phrase: vec![ PhrasePart::Word("dispose"), operand("material", ContractType::AnyMaterial, take), diff --git a/crates/lab-language/src/standard_library/mod.rs b/crates/lab-language/src/standard_library/mod.rs index d789d8d..8a197a9 100644 --- a/crates/lab-language/src/standard_library/mod.rs +++ b/crates/lab-language/src/standard_library/mod.rs @@ -5,10 +5,12 @@ //! `StandardLibrary`; it does not assign biological meaning by spelling. mod bio; +mod capability; mod catalog; mod contract; mod lab; pub mod manifest; +mod parameter; mod prelude; pub(crate) use catalog::{ diff --git a/crates/lab-language/src/standard_library/parameter.rs b/crates/lab-language/src/standard_library/parameter.rs new file mode 100644 index 0000000..036d9dc --- /dev/null +++ b/crates/lab-language/src/standard_library/parameter.rs @@ -0,0 +1,9 @@ +//! Absolute property-kind IRIs used by bundled durable action parameters. +//! +//! SBOLInventory property kinds are an open vocabulary. Lab uses explicit terms in the same +//! capability namespace as its operation vocabulary so requirements and facility offerings can +//! join without comparing source argument names. + +pub(crate) const TEMPERATURE: &str = "https://sbol.io/ns/capability#Temperature"; +pub(crate) const DURATION: &str = "https://sbol.io/ns/capability#Duration"; +pub(crate) const COUNT: &str = "https://sbol.io/ns/capability#Count"; diff --git a/crates/lab-package/src/lib.rs b/crates/lab-package/src/lib.rs index bdec929..d5d7e61 100644 --- a/crates/lab-package/src/lib.rs +++ b/crates/lab-package/src/lib.rs @@ -6,8 +6,9 @@ mod package; pub use graph::{ImportResolution, ModuleGraph, ModuleGraphError, ModuleNode}; pub use manifest::{ - BuildMetadata, DependencyDetail, DependencySpec, InventoryMetadata, LabManifest, - PackageManifest, PackageMetadata, WorkspaceManifest, WorkspaceMetadata, + AdapterBinding, BuildMetadata, DependencyDetail, DependencySpec, ExecutionMetadata, + InventoryMetadata, LabManifest, PackageManifest, PackageMetadata, WorkspaceManifest, + WorkspaceMetadata, }; pub use package::{ DiscoveredRoot, LabPackage, LabWorkspace, PackageError, PackageSource, SbolSyntax, diff --git a/crates/lab-package/src/manifest.rs b/crates/lab-package/src/manifest.rs index f7b90c9..0c37a80 100644 --- a/crates/lab-package/src/manifest.rs +++ b/crates/lab-package/src/manifest.rs @@ -1,5 +1,5 @@ use std::collections::{BTreeMap, BTreeSet}; -use std::path::PathBuf; +use std::path::{Component, PathBuf}; use serde::{Deserialize, Serialize}; @@ -10,7 +10,7 @@ use crate::PackageError; /// else, so member packages stay ordinary self-contained packages. #[derive(Clone, Debug, PartialEq, Eq)] pub enum LabManifest { - Package(PackageManifest), + Package(Box), Workspace(WorkspaceManifest), } @@ -20,7 +20,7 @@ impl LabManifest { if table.contains_key("workspace") { Ok(Self::Workspace(WorkspaceManifest::parse(text)?)) } else { - Ok(Self::Package(PackageManifest::parse(text)?)) + Ok(Self::Package(Box::new(PackageManifest::parse(text)?))) } } @@ -59,6 +59,8 @@ pub struct PackageManifest { #[serde(default)] pub inventory: InventoryMetadata, #[serde(default)] + pub execution: ExecutionMetadata, + #[serde(default)] pub dependencies: BTreeMap, } @@ -75,25 +77,44 @@ pub struct PackageMetadata { #[serde(deny_unknown_fields)] pub struct BuildMetadata { pub entry: Option, - /// Target profile a build compiles for when none is named on the command - /// line, resolved by filename under `targets/`. A package without one - /// builds portable module IR and stops. - pub target: Option, } -/// What a target build may draw on before it plans anything: the materials an -/// operator has on hand and the artifacts already realized. Names are the -/// symbolic identities `src/` declares, so the inventory reads as a statement -/// about this package's stock rather than as an opaque data file. +/// The facility catalog a package may plan against. +/// +/// `document` selects the portable SBOLInventory graph and `facility` disambiguates +/// that graph when it contains several facilities. The symbolic sets remain only +/// as a mutually exclusive migration form for existing packages. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct InventoryMetadata { + pub document: Option, + pub facility: Option, #[serde(default)] pub materials: BTreeSet, #[serde(default)] pub artifacts: BTreeSet, } +/// Local operational bindings from exact SBOLInventory Assets to Lab adapters. +/// +/// These records do not describe the facility. Manufacturer, model, capabilities, qualification, +/// and control mode remain facts in the inventory graph. A binding only states which installed +/// Lab implementation and non-secret profile may operate one exact catalog Asset. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExecutionMetadata { + #[serde(default)] + pub adapters: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdapterBinding { + pub asset: String, + pub driver: String, + pub profile: PathBuf, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(untagged)] pub enum DependencySpec { @@ -170,13 +191,8 @@ impl PackageManifest { self.package.edition.clone(), )); } - // A target is a filename under `targets/`, so it must not be able to - // reach outside that directory. - if let Some(target) = &self.build.target - && !valid_target_name(target) - { - return Err(PackageError::InvalidTarget(target.clone())); - } + self.inventory.validate()?; + self.execution.validate(&self.inventory)?; for (name, dependency) in &self.dependencies { if !valid_package_name(name) { return Err(PackageError::InvalidDependency { @@ -225,14 +241,125 @@ impl PackageManifest { } } +impl InventoryMetadata { + pub fn uses_legacy_symbols(&self) -> bool { + !self.materials.is_empty() || !self.artifacts.is_empty() + } + + fn validate(&self) -> Result<(), PackageError> { + if let Some(document) = &self.document { + if self.uses_legacy_symbols() { + return Err(PackageError::InvalidInventory( + "'document' cannot be combined with legacy 'materials' or 'artifacts'" + .to_owned(), + )); + } + let invalid = document.as_os_str().is_empty() + || document.is_absolute() + || document.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }); + if invalid { + return Err(PackageError::InvalidInventory(format!( + "document '{}' must be a package-relative path without '..'", + document.display() + ))); + } + } else if self.facility.is_some() { + return Err(PackageError::InvalidInventory( + "'facility' requires an inventory 'document'".to_owned(), + )); + } + Ok(()) + } +} + +impl ExecutionMetadata { + fn validate(&self, inventory: &InventoryMetadata) -> Result<(), PackageError> { + if !self.adapters.is_empty() && inventory.document.is_none() { + return Err(PackageError::InvalidExecution( + "adapter bindings require an SBOLInventory 'document'".to_owned(), + )); + } + + let mut bindings = BTreeSet::new(); + for adapter in &self.adapters { + if !valid_absolute_iri(&adapter.asset) { + return Err(PackageError::InvalidExecution(format!( + "adapter asset '{}' must be an absolute IRI", + adapter.asset + ))); + } + if !valid_adapter_id(&adapter.driver) { + return Err(PackageError::InvalidExecution(format!( + "adapter driver '{}' must be a lowercase dotted identifier", + adapter.driver + ))); + } + if !valid_relative_path(&adapter.profile) { + return Err(PackageError::InvalidExecution(format!( + "adapter profile '{}' must be a package-relative path without '..'", + adapter.profile.display() + ))); + } + if !bindings.insert((&adapter.asset, &adapter.driver)) { + return Err(PackageError::InvalidExecution(format!( + "asset '{}' binds adapter '{}' more than once", + adapter.asset, adapter.driver + ))); + } + } + Ok(()) + } +} + fn default_edition() -> String { "2026".to_owned() } -fn valid_target_name(name: &str) -> bool { - !name.is_empty() - && name.chars().all(|character| { - character.is_ascii_alphanumeric() || character == '-' || character == '_' +fn valid_adapter_id(value: &str) -> bool { + !value.is_empty() + && value.split('.').all(|segment| { + !segment.is_empty() + && segment.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + }) + }) +} + +fn valid_absolute_iri(value: &str) -> bool { + let Some((scheme, rest)) = value.split_once(':') else { + return false; + }; + !rest.is_empty() + && scheme + .chars() + .next() + .is_some_and(|character| character.is_ascii_alphabetic()) + && scheme.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.') + }) + && !value.chars().any(|character| { + character.is_whitespace() + || character.is_control() + || matches!( + character, + '<' | '>' | '"' | '{' | '}' | '|' | '\\' | '^' | '`' + ) + }) +} + +fn valid_relative_path(path: &std::path::Path) -> bool { + !path.as_os_str().is_empty() + && !path.is_absolute() + && !path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) }) } @@ -314,7 +441,7 @@ default-member = "packages/device" } #[test] - fn reads_the_default_target_and_rejects_one_that_is_not_a_profile_name() { + fn build_metadata_rejects_the_removed_target_selector() { let manifest = PackageManifest::parse( r#"[package] name = "tet-reporter" @@ -324,23 +451,15 @@ version = "0.1.0" entry = "src/programs/main.lab" target = "opentrons-ot2" "#, - ) - .unwrap(); - assert_eq!(manifest.build.target.as_deref(), Some("opentrons-ot2")); - manifest.validate().unwrap(); - - let escaping = PackageManifest::parse( - "[package]\nname = \"tet-reporter\"\nversion = \"0.1.0\"\n\n[build]\ntarget = \"../benches/ot2\"\n", - ) - .unwrap(); - assert!(matches!( - escaping.validate(), - Err(PackageError::InvalidTarget(_)) - )); + ); + assert!( + manifest.is_err(), + "facility allocation replaces build targets" + ); } #[test] - fn reads_the_inventory_a_target_build_resolves_against() { + fn reads_the_legacy_symbolic_inventory() { let manifest = PackageManifest::parse( r#"[package] name = "tet-reporter" @@ -373,6 +492,189 @@ artifacts = ["composite_plasmid_1"] ); } + #[test] + fn reads_an_sbol_inventory_document_and_optional_facility() { + let manifest = PackageManifest::parse( + r#"[package] +name = "tet-reporter" +version = "0.1.0" + +[inventory] +document = "inventory/ebef.ttl" +facility = "https://example.org/ebef/facility" +"#, + ) + .unwrap(); + + assert_eq!( + manifest.inventory.document.as_deref(), + Some(std::path::Path::new("inventory/ebef.ttl")) + ); + assert_eq!( + manifest.inventory.facility.as_deref(), + Some("https://example.org/ebef/facility") + ); + manifest.validate().unwrap(); + } + + #[test] + fn reads_explicit_adapter_bindings_to_exact_assets() { + let manifest = PackageManifest::parse( + r#"[package] +name = "tet-reporter" +version = "0.1.0" + +[inventory] +document = "inventory/facility.ttl" + +[[execution.adapters]] +asset = "https://example.org/facility/star-1" +driver = "hamilton.star" +profile = "adapters/star-1.toml" + +[[execution.adapters]] +asset = "https://example.org/facility/cycler-1" +driver = "inheco.odtc" +profile = "adapters/cycler-1.toml" +"#, + ) + .unwrap(); + + assert_eq!(manifest.execution.adapters.len(), 2); + assert_eq!( + manifest.execution.adapters[0].asset, + "https://example.org/facility/star-1" + ); + assert_eq!(manifest.execution.adapters[0].driver, "hamilton.star"); + assert_eq!( + manifest.execution.adapters[0].profile, + PathBuf::from("adapters/star-1.toml") + ); + manifest.validate().unwrap(); + } + + #[test] + fn rejects_non_portable_or_duplicate_adapter_bindings() { + let without_inventory = PackageManifest::parse( + r#"[package] +name = "test" +version = "0.1.0" + +[[execution.adapters]] +asset = "https://example.org/facility/star-1" +driver = "hamilton.star" +profile = "adapters/star-1.toml" +"#, + ) + .unwrap(); + assert!(matches!( + without_inventory.validate(), + Err(PackageError::InvalidExecution(_)) + )); + + let escaping_profile = PackageManifest::parse( + r#"[package] +name = "test" +version = "0.1.0" + +[inventory] +document = "inventory/facility.ttl" + +[[execution.adapters]] +asset = "https://example.org/facility/star-1" +driver = "hamilton.star" +profile = "../private/star-1.toml" +"#, + ) + .unwrap(); + assert!(matches!( + escaping_profile.validate(), + Err(PackageError::InvalidExecution(_)) + )); + + let duplicate = PackageManifest::parse( + r#"[package] +name = "test" +version = "0.1.0" + +[inventory] +document = "inventory/facility.ttl" + +[[execution.adapters]] +asset = "https://example.org/facility/star-1" +driver = "hamilton.star" +profile = "adapters/star-a.toml" + +[[execution.adapters]] +asset = "https://example.org/facility/star-1" +driver = "hamilton.star" +profile = "adapters/star-b.toml" +"#, + ) + .unwrap(); + assert!(matches!( + duplicate.validate(), + Err(PackageError::InvalidExecution(_)) + )); + + let invalid_driver = PackageManifest::parse( + r#"[package] +name = "test" +version = "0.1.0" + +[inventory] +document = "inventory/facility.ttl" + +[[execution.adapters]] +asset = "https://example.org/facility/star-1" +driver = "Hamilton STAR" +profile = "adapters/star.toml" +"#, + ) + .unwrap(); + assert!(matches!( + invalid_driver.validate(), + Err(PackageError::InvalidExecution(_)) + )); + } + + #[test] + fn rejects_ambiguous_or_non_portable_inventory_configuration() { + let mixed = PackageManifest::parse( + r#"[package] +name = "test" +version = "0.1.0" + +[inventory] +document = "inventory/catalog.ttl" +materials = ["BsaI"] +"#, + ) + .unwrap(); + assert!(matches!( + mixed.validate(), + Err(PackageError::InvalidInventory(_)) + )); + + let escaping = PackageManifest::parse( + "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[inventory]\ndocument = \"../catalog.ttl\"\n", + ) + .unwrap(); + assert!(matches!( + escaping.validate(), + Err(PackageError::InvalidInventory(_)) + )); + + let selector_only = PackageManifest::parse( + "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[inventory]\nfacility = \"https://example.org/facility\"\n", + ) + .unwrap(); + assert!(matches!( + selector_only.validate(), + Err(PackageError::InvalidInventory(_)) + )); + } + #[test] fn rejects_incoherent_dependency_sources() { let manifest = PackageManifest::parse( diff --git a/crates/lab-package/src/package.rs b/crates/lab-package/src/package.rs index 76f67c7..0114597 100644 --- a/crates/lab-package/src/package.rs +++ b/crates/lab-package/src/package.rs @@ -88,7 +88,7 @@ impl LabWorkspace { /// What a `lab.toml` found by an upward search turned out to be. #[derive(Clone, Debug, PartialEq, Eq)] pub enum DiscoveredRoot { - Package(LabPackage), + Package(Box), Workspace(LabWorkspace), } @@ -96,7 +96,7 @@ impl DiscoveredRoot { pub fn discover(start: impl AsRef) -> Result { let root = find_manifest_directory(start.as_ref())?; match read_manifest(&root)? { - LabManifest::Package(_) => Ok(Self::Package(LabPackage::load(root)?)), + LabManifest::Package(_) => Ok(Self::Package(Box::new(LabPackage::load(root)?))), LabManifest::Workspace(manifest) => { Ok(Self::Workspace(LabWorkspace { root, manifest })) } @@ -139,10 +139,10 @@ pub enum PackageError { UnsupportedEdition(String), #[error("invalid dependency '{name}': {message}")] InvalidDependency { name: String, message: String }, - #[error( - "invalid default target '{0}'; a target names a profile under 'targets/' using letters, digits, '-' or '_'" - )] - InvalidTarget(String), + #[error("invalid inventory configuration: {0}")] + InvalidInventory(String), + #[error("invalid execution configuration: {0}")] + InvalidExecution(String), #[error("package '{package}' has no Lab source modules under {source_root}")] NoSources { package: String, @@ -204,7 +204,7 @@ fn read_manifest(root: &Path) -> Result { impl LabPackage { pub fn discover(start: impl AsRef) -> Result { match DiscoveredRoot::discover(start)? { - DiscoveredRoot::Package(package) => Ok(package), + DiscoveredRoot::Package(package) => Ok(*package), DiscoveredRoot::Workspace(workspace) => Err(PackageError::NotAPackage { path: workspace.root, }), @@ -214,7 +214,7 @@ impl LabPackage { pub fn load(root: impl AsRef) -> Result { let root = root.as_ref().to_path_buf(); let manifest = match read_manifest(&root)? { - LabManifest::Package(manifest) => manifest, + LabManifest::Package(manifest) => *manifest, LabManifest::Workspace(_) => { return Err(PackageError::NotAPackage { path: root }); } diff --git a/crates/lab-project/src/lib.rs b/crates/lab-project/src/lib.rs index 11bbdef..608593f 100644 --- a/crates/lab-project/src/lib.rs +++ b/crates/lab-project/src/lib.rs @@ -189,8 +189,8 @@ impl LabProject { /// The packages that make up one runnable program: the default member and /// everything it depends on, in dependency-first compilation order. A - /// target build lowers exactly these packages' modules together, so an - /// artifact declared in a dependency reaches the backend. + /// package build lowers exactly these packages' modules together, so an + /// artifact declared in a dependency reaches planning and adapter lowering. pub fn program_packages(&self) -> Vec { let mut reachable = BTreeSet::new(); self.collect_reachable(&self.default_member, &mut reachable); @@ -802,4 +802,12 @@ workflow main() -> Material: ); assert!(LabProject::discover(root).unwrap().compile().is_ok()); } + + #[test] + fn ebef_reference_package_compiles_as_a_portable_library() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/ebef"); + let compiled = LabProject::discover(root).unwrap().compile().unwrap(); + assert_eq!(compiled.modules.len(), 1); + assert_eq!(compiled.modules[0].source.module, "ebef_reference.facility"); + } } diff --git a/crates/lab-python/README.md b/crates/lab-python/README.md index 2ea8746..2a18f7a 100644 --- a/crates/lab-python/README.md +++ b/crates/lab-python/README.md @@ -82,6 +82,16 @@ That emits the `use` lines too. `Plasmid` imported from `lab.bio.golden_gate` is A declaration takes its Lab name from the Python name it is bound to, so nothing is spelled twice; one generated in a loop states its own `name`. A claim is a function so that the artifact's properties arrive through a parameter rather than appearing from nowhere, which is also what lets a typechecker see them. +A package can contribute an artifact kind without extending Lab's grammar. `lab.artifact` names its produced type and typed property schema, returns the same `build` and `buy` declaration interface as a standard-library kind, and treats `T | None` as an optional property: + +```python +Reagent = lab.artifact("Reagent", description=str | None) + +T4_DNA_ligase = Reagent.buy( + sbol_identity="https://example.org/materials/T4_DNA_ligase", +) +``` + `lab.check` emits the modules in dependency order and hands the result to the compiler: ```python diff --git a/crates/lab-python/python/lab/__init__.py b/crates/lab-python/python/lab/__init__.py index 300e503..dea042f 100644 --- a/crates/lab-python/python/lab/__init__.py +++ b/crates/lab-python/python/lab/__init__.py @@ -19,6 +19,7 @@ from typing import Any, cast from . import sbol as sbol +from ._artifacts import artifact from ._circuits import ( CircuitError, Network, @@ -181,6 +182,7 @@ def compile_lab_module(source: str) -> dict[str, Any]: "analyze", "analyze_sources", "and_", + "artifact", "assemble", "capture", "case", diff --git a/crates/lab-python/python/lab/_artifacts.py b/crates/lab-python/python/lab/_artifacts.py new file mode 100644 index 0000000..9d76eb6 --- /dev/null +++ b/crates/lab-python/python/lab/_artifacts.py @@ -0,0 +1,84 @@ +"""Typed declaration factories for package-local artifact kinds.""" + +from __future__ import annotations + +import re +import types +import typing +from collections.abc import Iterator +from typing import Any + +from ._declarations import ( + ArtifactField, + ArtifactKindDeclaration, + RecordDeclaration, + declaring_module, +) +from ._source import caller_origin +from ._types import LabType, lab_type, type_modules +from ._vocabulary import ArtifactKind + + +def artifact(name: str, **properties: object) -> type[ArtifactKind]: + """Declare a package-local artifact kind and return its typed declaration API. + + Keyword values are the kind's property types. `T | None` makes a property + optional; every other type is required. `name` is the produced Lab type, and + its snake-case spelling is the artifact declaration word. + """ + + module, _ = declaring_module(depth=2) + annotations = list(properties.items()) + fields = [_field(field, annotation) for field, annotation in annotations] + uses = tuple(_field_modules(annotations)) + origin = caller_origin(2) + module.declare( + RecordDeclaration( + module=module, + name=name, + origin=origin, + ) + ) + module.declare( + ArtifactKindDeclaration( + module=module, + name=name, + fields=fields, + uses=uses, + origin=origin, + ) + ) + return type( + name, + (ArtifactKind, LabType), + { + "__module__": module.name, + "word": _snake_case(name), + "uses": (module.name,), + "__lab_uses__": (module.name,), + "properties": tuple(field.name for field in fields), + }, + ) + + +def _field(name: str, annotation: object) -> ArtifactField: + origin = typing.get_origin(annotation) + arguments = typing.get_args(annotation) + if origin in (typing.Union, types.UnionType) and type(None) in arguments: + required = tuple(argument for argument in arguments if argument is not type(None)) + if len(required) != 1: + raise TypeError( + f"optional artifact property '{name}' must name exactly one non-None type" + ) + return ArtifactField(name=name, annotation=lab_type(required[0]), optional=True) + return ArtifactField(name=name, annotation=lab_type(annotation)) + + +def _field_modules(annotations: list[tuple[str, Any]]) -> Iterator[str]: + for _, annotation in annotations: + yield from type_modules(annotation) + + +def _snake_case(name: str) -> str: + words = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name) + return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", words).lower() diff --git a/crates/lab-python/python/lab/_circuits.py b/crates/lab-python/python/lab/_circuits.py index d1cd7cf..308f8fa 100644 --- a/crates/lab-python/python/lab/_circuits.py +++ b/crates/lab-python/python/lab/_circuits.py @@ -507,7 +507,7 @@ def _mint_part( if component is not None: identity = getattr(component, "identity", None) if identity: - stated = {"identity": str(identity), **stated} + stated = {"sbol_identity": str(identity), **stated} name = _naming.free_name(_naming.identifier(fallback), word, taken) declaration = BuyDeclaration( module=module, diff --git a/crates/lab-python/python/lab/_declarations.py b/crates/lab-python/python/lab/_declarations.py index d6e92d5..47a4a8b 100644 --- a/crates/lab-python/python/lab/_declarations.py +++ b/crates/lab-python/python/lab/_declarations.py @@ -302,6 +302,51 @@ def __repr__(self) -> str: return f"" +@dataclass(frozen=True) +class ArtifactField: + """One property contributed by a package-local artifact kind.""" + + name: str + annotation: str + optional: bool = False + + +class ArtifactKindDeclaration: + """An `artifact` declaration connecting an instance word to a typed schema.""" + + def __init__( + self, + *, + module: Module, + name: str, + fields: Sequence[ArtifactField] = (), + uses: Sequence[str] = (), + origin: Origin | None = None, + ) -> None: + self.module = module + self.name = name + self.fields = list(fields) + self.uses = tuple(uses) + self.origin = origin + + def lab_modules(self) -> Iterator[str]: + yield from self.uses + + def write(self, writer: SourceWriter) -> None: + with writer.region(self.origin): + if not self.fields: + writer.line(f"artifact {self.name}") + return + writer.line(f"artifact {self.name}:") + with writer.indented(): + for field in self.fields: + optional = "?" if field.optional else "" + writer.line(f"{field.name}{optional}: {field.annotation}") + + def __repr__(self) -> str: + return f"" + + class CircuitDeclaration: """A `circuit` declaration: typed inputs, an output type, and a layout. @@ -496,7 +541,12 @@ def lab_modules(self) -> Iterator[str]: #: Everything a module can hold, in the order it is written. ModuleItem = ( - Declaration[Any] | RecordDeclaration | CircuitDeclaration | WorkflowDeclaration | Binding + Declaration[Any] + | RecordDeclaration + | ArtifactKindDeclaration + | CircuitDeclaration + | WorkflowDeclaration + | Binding ) @@ -613,7 +663,12 @@ def _module_path(target: object) -> str: #: What `emit`-style grouping produces: a run of bought declarations written as #: one block, or a single item that writes itself. Group = ( - list[Declaration[Any]] | RecordDeclaration | CircuitDeclaration | WorkflowDeclaration | Binding + list[Declaration[Any]] + | RecordDeclaration + | ArtifactKindDeclaration + | CircuitDeclaration + | WorkflowDeclaration + | Binding ) diff --git a/crates/lab-python/python/lab/_naming.py b/crates/lab-python/python/lab/_naming.py index e8d342f..9336e24 100644 --- a/crates/lab-python/python/lab/_naming.py +++ b/crates/lab-python/python/lab/_naming.py @@ -13,6 +13,7 @@ from collections.abc import Iterable from ._declarations import ( + ArtifactKindDeclaration, CircuitDeclaration, Module, RecordDeclaration, @@ -66,7 +67,10 @@ def taken_names(module: Module, prospective_uses: Iterable[str] = ()) -> set[str for path in (*module.imports(), *prospective_uses): taken.update(_mirror_exports(path)) for item in module.declarations: - if isinstance(item, RecordDeclaration | CircuitDeclaration | WorkflowDeclaration): + if isinstance( + item, + RecordDeclaration | ArtifactKindDeclaration | CircuitDeclaration | WorkflowDeclaration, + ): taken.add(item.name) elif item._name is not None: # Artifact declarations and bindings are named by a Python diff --git a/crates/lab-python/python/lab/_sbol.py b/crates/lab-python/python/lab/_sbol.py index d2c0ece..be146cd 100644 --- a/crates/lab-python/python/lab/_sbol.py +++ b/crates/lab-python/python/lab/_sbol.py @@ -148,9 +148,9 @@ def _read_typed_design( ) identity = _typed_identity(design) + properties["sbol_identity"] = identity if provenance == "buy": _remember_typed_buy(module, identity, before, design) - properties["identity"] = identity requirements: list[Claim] = [] topology = getattr(design, "topology", None) @@ -194,11 +194,10 @@ def _read_raw_design( fallback_name=f"{declaration_name}_sequence", ) - if provenance == "buy": - identity = getattr(raw, "identity", None) - if identity is None: - raise DesignError(f"a design passed to {kind.produces}.buy has no SBOL identity") - properties["identity"] = str(identity) + identity = getattr(raw, "identity", None) + if identity is None: + raise DesignError(f"a design passed to {kind.produces} has no SBOL identity") + properties["sbol_identity"] = str(identity) requirements: list[Claim] = [] if provenance == "build" and _terms.CIRCULAR in types: diff --git a/crates/lab-python/python/lab/_vocabulary.py b/crates/lab-python/python/lab/_vocabulary.py index f1e1192..a82cbdb 100644 --- a/crates/lab-python/python/lab/_vocabulary.py +++ b/crates/lab-python/python/lab/_vocabulary.py @@ -168,11 +168,11 @@ def buy( ) -> BuyDeclaration[_ArtifactKindT]: """Declare something a supplier lists. - It has an identity to resolve or order against and is never built, so it - takes no claims and no build order: `require` and `accept` belong to - building. A typed `design` contributes its registry identity, sequence, - and other biological facts without making the design factory itself - imply procurement. + It has a supplier identity to resolve or order against and is never + built, so it takes no claims and no build order: `require` and `accept` + belong to building. A typed `design` contributes its independent SBOL + Component identity, sequence, and other biological facts without making + the design factory itself imply procurement. """ found, scope = declaring_module(depth=2, given=module) diff --git a/crates/lab-python/python/lab/bio/designs.py b/crates/lab-python/python/lab/bio/designs.py index 1cee21b..1e76c16 100644 --- a/crates/lab-python/python/lab/bio/designs.py +++ b/crates/lab-python/python/lab/bio/designs.py @@ -6,8 +6,8 @@ declaration that names it, not by its kind. Each kind states the ontology terms it stands for, so what it is travels with -it. A target reading a design knows a backbone is DNA and an antibiotic is a -small molecule without being told separately. +it. Any consumer reading a design knows a backbone is DNA and an antibiotic +is a small molecule without being told separately. """ # Generated from the Lab standard library by `python -m lab.codegen`. Do not edit. diff --git a/crates/lab-python/tests/programs/golden_gate/inventory.py b/crates/lab-python/tests/programs/golden_gate/inventory.py index 2a8081c..b16b6dc 100644 --- a/crates/lab-python/tests/programs/golden_gate/inventory.py +++ b/crates/lab-python/tests/programs/golden_gate/inventory.py @@ -12,6 +12,10 @@ module = lab.Module("golden_gate.designs.inventory", doc=__doc__) + +Reagent = lab.artifact("Reagent", description=str | None) + + J23101_sequence = lab.Binding( module=module, name="J23101_sequence", @@ -61,30 +65,61 @@ # Constitutive promoters of differing strength. Each is a promoter rather # than a bare part, so the compiler knows what it is without being told # again wherever it is used. -J23101 = Promoter.buy(sequence=J23101_sequence) -J23106 = Promoter.buy(sequence=J23106_sequence) +J23101 = Promoter.buy( + sbol_identity="https://synbiohub.org/public/igem/J23101", + sequence=J23101_sequence, +) +J23106 = Promoter.buy( + sbol_identity="https://synbiohub.org/public/igem/J23106", + sequence=J23106_sequence, +) # The shared ribosome binding site and terminator. Neither has a narrower # kind here, so both are parts; a package that declares one may say more. -B0034 = Part.buy(sequence=B0034_sequence) -B0015 = Part.buy(sequence=B0015_sequence) +B0034 = Part.buy( + sbol_identity="https://synbiohub.org/public/igem/B0034", + sequence=B0034_sequence, +) +B0015 = Part.buy( + sbol_identity="https://synbiohub.org/public/igem/B0015", + sequence=B0015_sequence, +) # The fluorescent reporters, each a coding sequence. -GFP = CDS.buy(sequence=GFP_sequence) -RFP = CDS.buy(sequence=RFP_sequence) +GFP = CDS.buy( + sbol_identity="https://synbiohub.org/public/igem/GFP", + sequence=GFP_sequence, +) +RFP = CDS.buy( + sbol_identity="https://synbiohub.org/public/igem/RFP", + sequence=RFP_sequence, +) # Assembly backbone and the type IIS enzyme that opens it. -pSB1C3 = Backbone.buy() +pSB1C3 = Backbone.buy(sbol_identity="https://example.org/golden-gate/materials/pSB1C3") # BsaI cuts at 37 C; every plasmid it opens digests the same way. BsaI = RestrictionEnzyme.buy( + sbol_identity="https://example.org/golden-gate/materials/BsaI", digest_temperature=37 * C, digest_duration=2 * minutes, ) +T4_DNA_ligase = Reagent.buy(sbol_identity="https://example.org/golden-gate/materials/T4_DNA_ligase") +T4_DNA_ligase_buffer = Reagent.buy( + sbol_identity="https://example.org/golden-gate/materials/T4_DNA_ligase_buffer" +) +nuclease_free_water = Reagent.buy( + sbol_identity="https://example.org/golden-gate/materials/nuclease_free_water" +) +recovery_medium = Reagent.buy( + sbol_identity="https://example.org/golden-gate/materials/recovery_medium" +) + # Host organisms. DH5alpha is a cloning strain; BL21 is an expression strain. # Both are transformed the way competent cells are: chilled, shocked, recovered. DH5alpha = Chassis.buy( + sbol_identity="https://example.org/golden-gate/materials/DH5alpha", heat_shock_temperature=42 * C, cold_incubation=30 * minutes, recovery_temperature=37 * C, @@ -92,10 +127,13 @@ ) BL21 = Chassis.buy( + sbol_identity="https://example.org/golden-gate/materials/BL21", heat_shock_temperature=42 * C, cold_incubation=30 * minutes, recovery_temperature=37 * C, recovery_duration=60 * minutes, ) -chloramphenicol = Antibiotic.buy() +chloramphenicol = Antibiotic.buy( + sbol_identity="https://example.org/golden-gate/materials/chloramphenicol" +) diff --git a/crates/lab-python/tests/programs/golden_gate/plasmids.py b/crates/lab-python/tests/programs/golden_gate/plasmids.py index 155e6ec..af233f2 100644 --- a/crates/lab-python/tests/programs/golden_gate/plasmids.py +++ b/crates/lab-python/tests/programs/golden_gate/plasmids.py @@ -7,8 +7,7 @@ computes an assembled sequence rather than taking one on trust. The reaction chemistry in each design is scientific intent and travels with -the artifact; where the reaction physically happens is a target profile's -concern. +the artifact; facility allocation determines where it physically happens. """ import lab @@ -47,6 +46,7 @@ J23101 drives GFP through the shared RBS and terminator, assembled by Golden Gate with BsaI. Accepted only if the built sequence matches the design. """, + sbol_identity="https://example.org/golden-gate/designs/composite_plasmid_1", sequence=composite_plasmid_1_sequence, backbone=pSB1C3, components=[J23101, B0034, GFP, B0015], @@ -71,6 +71,7 @@ J23106 promoter, so the panel reports two promoter strengths against two reporters. """, + sbol_identity="https://example.org/golden-gate/designs/composite_plasmid_2", sequence=composite_plasmid_2_sequence, backbone=pSB1C3, components=[J23106, B0034, RFP, B0015], diff --git a/crates/lab-python/tests/programs/golden_gate/strains.py b/crates/lab-python/tests/programs/golden_gate/strains.py index 5b71f7e..097a6d0 100644 --- a/crates/lab-python/tests/programs/golden_gate/strains.py +++ b/crates/lab-python/tests/programs/golden_gate/strains.py @@ -32,6 +32,7 @@ composite_strain_1 = Strain.build( doc="The GFP reporter carried in the DH5alpha cloning strain.", + sbol_identity="https://example.org/golden-gate/designs/composite_strain_1", chassis=DH5alpha, plasmids=[composite_plasmid_1], selection=chloramphenicol, @@ -43,6 +44,7 @@ composite_strain_2 = Strain.build( doc="The RFP reporter carried in the DH5alpha cloning strain.", + sbol_identity="https://example.org/golden-gate/designs/composite_strain_2", chassis=DH5alpha, plasmids=[composite_plasmid_2], selection=chloramphenicol, @@ -54,6 +56,7 @@ composite_strain_3 = Strain.build( doc="The GFP reporter carried in the BL21 expression strain.", + sbol_identity="https://example.org/golden-gate/designs/composite_strain_3", chassis=BL21, plasmids=[composite_plasmid_1], selection=chloramphenicol, @@ -65,6 +68,7 @@ composite_strain_4 = Strain.build( doc="The RFP reporter carried in the BL21 expression strain.", + sbol_identity="https://example.org/golden-gate/designs/composite_strain_4", chassis=BL21, plasmids=[composite_plasmid_2], selection=chloramphenicol, diff --git a/crates/lab-python/tests/programs/reporter/plasmid.py b/crates/lab-python/tests/programs/reporter/plasmid.py index 05622ac..8195a4c 100644 --- a/crates/lab-python/tests/programs/reporter/plasmid.py +++ b/crates/lab-python/tests/programs/reporter/plasmid.py @@ -38,7 +38,7 @@ design=designs.backbone(identity=f"{IGEM}/pSB1C3/1"), ) BsaI = RestrictionEnzyme.buy( - identity="NEB-R0535", + supplier_identity="NEB-R0535", digest_temperature=37 * C, digest_duration=2 * minutes, ) diff --git a/crates/lab-python/tests/test_loica_circuits.py b/crates/lab-python/tests/test_loica_circuits.py index f17b1a5..9adf4ef 100644 --- a/crates/lab-python/tests/test_loica_circuits.py +++ b/crates/lab-python/tests/test_loica_circuits.py @@ -45,25 +45,25 @@ buy: promoter pLac: Promoter: - identity = "https://example.org/repressilator/pLac" + sbol_identity = "https://example.org/repressilator/pLac" regulation = repressed cds TetR_cds: CDS: - identity = "https://example.org/repressilator/TetR" + sbol_identity = "https://example.org/repressilator/TetR" promoter pTet_promoter: Promoter: - identity = "https://example.org/repressilator/pTet" + sbol_identity = "https://example.org/repressilator/pTet" regulation = repressed cds CI_cds: CDS: - identity = "https://example.org/repressilator/CI" + sbol_identity = "https://example.org/repressilator/CI" promoter pCI: Promoter: - identity = "https://example.org/repressilator/pCI" + sbol_identity = "https://example.org/repressilator/pCI" regulation = repressed cds LacI_cds: CDS: - identity = "https://example.org/repressilator/LacI" + sbol_identity = "https://example.org/repressilator/LacI" /** * One transcription unit: a promoter driving a coding sequence diff --git a/crates/lab-python/tests/test_sbol_designs.py b/crates/lab-python/tests/test_sbol_designs.py index 0a560e6..479c993 100644 --- a/crates/lab-python/tests/test_sbol_designs.py +++ b/crates/lab-python/tests/test_sbol_designs.py @@ -33,27 +33,28 @@ buy: promoter J23101: - identity = "https://synbiohub.org/public/igem/BBa_J23101/1" + sbol_identity = "https://synbiohub.org/public/igem/BBa_J23101/1" part B0034: - identity = "https://synbiohub.org/public/igem/BBa_B0034/1" + sbol_identity = "https://synbiohub.org/public/igem/BBa_B0034/1" cds GFP: - identity = "https://synbiohub.org/public/igem/BBa_E0040/1" + sbol_identity = "https://synbiohub.org/public/igem/BBa_E0040/1" part B0015: - identity = "https://synbiohub.org/public/igem/BBa_B0015/1" + sbol_identity = "https://synbiohub.org/public/igem/BBa_B0015/1" backbone pSB1C3: - identity = "https://synbiohub.org/public/igem/pSB1C3/1" + sbol_identity = "https://synbiohub.org/public/igem/pSB1C3/1" restriction_enzyme BsaI: - identity = "NEB-R0535" + supplier_identity = "NEB-R0535" digest_temperature = 37 C digest_duration = 2 min /** The GFP reporter under a strong constitutive promoter. */ build plasmid reporter: + sbol_identity = "https://synbiohub.org/user/marpaia/reporter/reporter" components = [J23101, B0034, GFP, B0015] sequence = reporter_sequence backbone = pSB1C3 diff --git a/crates/lab-python/tests/test_typed_sbol.py b/crates/lab-python/tests/test_typed_sbol.py index 58a54d7..88ba011 100644 --- a/crates/lab-python/tests/test_typed_sbol.py +++ b/crates/lab-python/tests/test_typed_sbol.py @@ -137,7 +137,7 @@ def test_a_bought_design_contributes_its_registry_identity(self) -> None: source = module.source() self.assertIsInstance(declaration, lab.BuyDeclaration) - self.assertIn(f'identity = "{identity}"', source) + self.assertIn(f'sbol_identity = "{identity}"', source) self.assertNotIn("require topology", source) def test_module_emission_materializes_and_validates_only_once(self) -> None: @@ -326,7 +326,7 @@ def test_vocabulary_classes_with_the_same_kind_share_one_semantic_kind(self) -> LabPlasmid.buy(design=second, module=module, name="second") source = module.source() - self.assertEqual(source.count(f'identity = "{identity}"'), 2) + self.assertEqual(source.count(f'sbol_identity = "{identity}"'), 2) def test_raw_py_sbol_objects_remain_an_explicit_escape_hatch(self) -> None: module = lab.Module("raw.escape") diff --git a/crates/lab-runfmt/src/lib.rs b/crates/lab-runfmt/src/lib.rs index de4e35b..9695768 100644 --- a/crates/lab-runfmt/src/lib.rs +++ b/crates/lab-runfmt/src/lib.rs @@ -10,7 +10,8 @@ //! loaders in this crate, so a wrong or missing format string fails the same //! way everywhere. -use std::path::Path; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::path::{Component, Path}; use serde::{Deserialize, Serialize}; @@ -23,11 +24,14 @@ pub const THERMOCYCLE_RUN_FORMAT: &str = "lab.thermocycle-run.v0"; /// The format string every `lab.plate-read.v0` document declares. pub const PLATE_READ_FORMAT: &str = "lab.plate-read.v0"; -/// The format string every `lab.workcell-run.v0` document declares. -pub const WORKCELL_RUN_FORMAT: &str = "lab.workcell-run.v0"; +/// The format string every semantic capability simulation document declares. +pub const SIMULATION_RUN_FORMAT: &str = "lab.simulation-run.v1"; -/// The file name a wave directory's coordination plan is stored under. -pub const WORKCELL_PLAN_FILE: &str = "plan.workcell.json"; +/// The reviewed, facility-wide execution plan format. +pub const EXECUTION_PLAN_FORMAT: &str = "lab.execution-plan.v1"; + +/// The well-known file name for a facility-wide reviewed plan. +pub const EXECUTION_PLAN_FILE: &str = "plan.execution.json"; /// Why a run document failed to load. #[derive(Debug, thiserror::Error)] @@ -50,6 +54,8 @@ pub enum RunDocumentError { expected: &'static str, found: String, }, + #[error("{path} is not a valid execution plan: {message}")] + InvalidPlan { path: String, message: String }, } fn load_document(path: &Path) -> Result @@ -99,16 +105,532 @@ pub fn load_plate_read(path: &Path) -> Result Result { - let path = directory.join(WORKCELL_PLAN_FILE); - let document: WorkcellRunDocument = load_document(&path)?; - check_format(&path, WORKCELL_RUN_FORMAT, &document.format)?; +/// Load and format-check one `lab.simulation-run.v1` document. +pub fn load_simulation_run(path: &Path) -> Result { + let document: SimulationRunDocument = load_document(path)?; + check_format(path, SIMULATION_RUN_FORMAT, &document.format)?; + Ok(document) +} + +/// Load, format-check, and structurally validate one `lab.execution-plan.v1` document. +pub fn load_execution_plan(path: &Path) -> Result { + let document: ExecutionPlanDocument = load_document(path)?; + check_format(path, EXECUTION_PLAN_FORMAT, &document.format)?; + document + .validate() + .map_err(|message| RunDocumentError::InvalidPlan { + path: path.display().to_string(), + message, + })?; Ok(document) } +/// One reviewed facility-wide plan. Runtime interpretation is restricted to these frozen facts. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ExecutionPlanDocument { + /// Always [`EXECUTION_PLAN_FORMAT`]. + pub format: String, + pub inventory: ExecutionInventoryReference, + pub requirements: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub materials: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub outputs: Vec, + /// Immutable whole-program adapter outputs that implement several semantic requirements + /// together and therefore cannot be attached honestly to one Execute node. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub lowerings: Vec, + pub nodes: Vec, +} + +impl ExecutionPlanDocument { + pub fn validate(&self) -> Result<(), String> { + if self.format != EXECUTION_PLAN_FORMAT { + return Err(format!( + "format is '{}', expected '{EXECUTION_PLAN_FORMAT}'", + self.format + )); + } + require_sha256("inventory source", &self.inventory.source_sha256)?; + require_relative_path("inventory source", &self.inventory.document)?; + + let mut requirements = BTreeMap::new(); + for requirement in &self.requirements { + if requirement.requirement_instance.is_empty() { + return Err("a requirement binding has an empty instance ID".to_owned()); + } + if requirements + .insert(requirement.requirement_instance.as_str(), requirement) + .is_some() + { + return Err(format!( + "requirement instance '{}' is bound more than once", + requirement.requirement_instance + )); + } + if let Some(adapter) = &requirement.adapter { + require_sha256( + &format!("adapter profile for '{}'", requirement.requirement_instance), + &adapter.profile_sha256, + )?; + require_relative_path("adapter profile", &adapter.profile_path)?; + } + } + + let mut materials = BTreeSet::new(); + for material in &self.materials { + if material.id.is_empty() || !materials.insert(material.id.as_str()) { + return Err(format!( + "material binding ID '{}' is empty or repeated", + material.id + )); + } + } + let mut material_lots = self + .materials + .iter() + .map(|material| material.material_lot.as_str()) + .collect::>(); + for output in &self.outputs { + if output.id.is_empty() || !materials.insert(output.id.as_str()) { + return Err(format!( + "output material binding ID '{}' is empty or repeated", + output.id + )); + } + if output.namespace.ends_with('/') + || output.namespace.is_empty() + || output.display_id.is_empty() + || output.material_lot != format!("{}/{}", output.namespace, output.display_id) + { + return Err(format!( + "output material '{}' identity must equal namespace/display_id", + output.id + )); + } + if !material_lots.insert(output.material_lot.as_str()) { + return Err(format!( + "material lot IRI '{}' is bound more than once", + output.material_lot + )); + } + for source in &output.derived_from { + if !self.materials.iter().any(|material| material.id == *source) { + return Err(format!( + "output material '{}' derives from unknown input material '{}'", + output.id, source + )); + } + } + } + + let mut lowering_ids = BTreeSet::new(); + let mut lowered_requirements = BTreeSet::new(); + let mut lowering_artifact_paths = BTreeSet::new(); + for lowering in &self.lowerings { + if lowering.id.is_empty() || !lowering_ids.insert(lowering.id.as_str()) { + return Err(format!( + "adapter lowering ID '{}' is empty or repeated", + lowering.id + )); + } + if lowering.asset.is_empty() { + return Err(format!( + "adapter lowering '{}' has an empty Asset IRI", + lowering.id + )); + } + require_relative_path("adapter lowering profile", &lowering.adapter.profile_path)?; + require_sha256( + &format!("adapter lowering profile for '{}'", lowering.id), + &lowering.adapter.profile_sha256, + )?; + if lowering.requirements.is_empty() { + return Err(format!( + "adapter lowering '{}' does not identify any triggering requirements", + lowering.id + )); + } + let mut route_requirements = BTreeSet::new(); + for requirement_id in &lowering.requirements { + if !route_requirements.insert(requirement_id.as_str()) { + return Err(format!( + "adapter lowering '{}' repeats requirement '{}'", + lowering.id, requirement_id + )); + } + if !lowered_requirements.insert(requirement_id.as_str()) { + return Err(format!( + "requirement '{}' belongs to more than one adapter lowering", + requirement_id + )); + } + let requirement = requirements.get(requirement_id.as_str()).ok_or_else(|| { + format!( + "adapter lowering '{}' references unknown requirement '{}'", + lowering.id, requirement_id + ) + })?; + if requirement.asset != lowering.asset { + return Err(format!( + "adapter lowering '{}' binds Asset '{}', but requirement '{}' binds '{}'", + lowering.id, lowering.asset, requirement_id, requirement.asset + )); + } + if requirement.adapter.as_ref() != Some(&lowering.adapter) { + return Err(format!( + "adapter lowering '{}' does not match the frozen adapter for requirement '{}'", + lowering.id, requirement_id + )); + } + } + if lowering.artifacts.is_empty() { + return Err(format!( + "adapter lowering '{}' has no reviewed artifacts", + lowering.id + )); + } + let mut device_protocols = 0; + for artifact in &lowering.artifacts { + require_relative_path("reviewed lowering artifact", &artifact.path)?; + require_sha256( + &format!( + "reviewed lowering artifact '{}' in '{}'", + artifact.path, lowering.id + ), + &artifact.sha256, + )?; + if artifact.media_type.is_empty() { + return Err(format!( + "reviewed lowering artifact '{}' in '{}' has no media type", + artifact.path, lowering.id + )); + } + if !lowering_artifact_paths.insert(artifact.path.as_str()) { + return Err(format!( + "reviewed lowering artifact path '{}' is repeated", + artifact.path + )); + } + match artifact.role { + ReviewedLoweringArtifactRole::DeviceProtocol => { + device_protocols += 1; + if artifact.format.as_deref().is_none_or(str::is_empty) { + return Err(format!( + "device protocol '{}' in '{}' has no run-document format", + artifact.path, lowering.id + )); + } + } + ReviewedLoweringArtifactRole::OperatorDocument + | ReviewedLoweringArtifactRole::Support => { + if artifact.format.is_some() { + return Err(format!( + "non-protocol artifact '{}' in '{}' declares a run-document format", + artifact.path, lowering.id + )); + } + } + } + } + if device_protocols == 0 { + return Err(format!( + "adapter lowering '{}' has no reviewed device protocol", + lowering.id + )); + } + } + + let mut nodes = BTreeMap::new(); + for node in &self.nodes { + if node.id.is_empty() || nodes.insert(node.id.as_str(), node).is_some() { + return Err(format!("node ID '{}' is empty or repeated", node.id)); + } + } + for node in &self.nodes { + let mut dependencies = BTreeSet::new(); + for dependency in &node.after { + if !dependencies.insert(dependency) { + return Err(format!( + "node '{}' repeats dependency '{}'", + node.id, dependency + )); + } + if dependency == &node.id { + return Err(format!("node '{}' depends on itself", node.id)); + } + if !nodes.contains_key(dependency.as_str()) { + return Err(format!( + "node '{}' depends on unknown node '{}'", + node.id, dependency + )); + } + } + match &node.action { + ExecutionPlanAction::Execute { + requirement, + document, + } => { + if !requirements.contains_key(requirement.as_str()) { + return Err(format!( + "execute node '{}' references unknown requirement '{}'", + node.id, requirement + )); + } + if let Some(document) = document { + if lowered_requirements.contains(requirement.as_str()) { + return Err(format!( + "execute node '{}' attaches a single run document to requirement '{}', which already belongs to whole-program adapter lowering", + node.id, requirement + )); + } + require_relative_path("reviewed run document", &document.path)?; + require_sha256( + &format!("reviewed run document for node '{}'", node.id), + &document.sha256, + )?; + if document.format.is_empty() { + return Err(format!( + "reviewed run document for node '{}' has no format", + node.id + )); + } + } + } + ExecutionPlanAction::MoveMaterial { material, .. } => { + if !materials.contains(material.as_str()) { + return Err(format!( + "material-movement node '{}' references unknown material binding '{}'", + node.id, material + )); + } + } + ExecutionPlanAction::Manual { title, .. } if title.is_empty() => { + return Err(format!("manual node '{}' has an empty title", node.id)); + } + ExecutionPlanAction::Manual { .. } => {} + } + } + validate_acyclic(&nodes) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExecutionInventoryReference { + /// Exact source graph copied into the reviewed execution package. + pub document: String, + pub source_sha256: String, + pub facility: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ExecutionRequirementBinding { + pub requirement_instance: String, + pub requirement_template: String, + pub capability_kind: String, + pub offering: String, + pub asset: String, + pub minimum_qualification: String, + pub observed_qualification: String, + pub control_mode: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub parameters: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub adapter: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExecutionParameterBinding { + pub argument: String, + pub property_kind: String, + pub relation: String, + #[serde(flatten)] + pub required: ExecutionParameterValue, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub required_unit: Option, + pub offering_parameter: String, + pub observed: ExecutionParameterValue, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_unit: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "value_type", content = "value", rename_all = "snake_case")] +pub enum ExecutionParameterValue { + Text(String), + Integer(String), + Real(String), + Boolean(bool), + Iri(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExecutionAdapterBinding { + pub driver: String, + pub profile_path: String, + pub profile_sha256: String, +} + +/// One immutable adapter invocation whose device artifacts jointly realize several requirements. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExecutionLoweringBundle { + pub id: String, + pub asset: String, + pub adapter: ExecutionAdapterBinding, + pub requirements: Vec, + pub artifacts: Vec, +} + +/// One hash-addressed child of a reviewed whole-program adapter lowering. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewedLoweringArtifact { + pub path: String, + pub media_type: String, + pub sha256: String, + pub role: ReviewedLoweringArtifactRole, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewedLoweringArtifactRole { + DeviceProtocol, + OperatorDocument, + Support, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExecutionMaterialBinding { + pub id: String, + pub component: String, + pub material_lot: String, +} + +/// One new MaterialLot whose exact identity and lineage are frozen before execution. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExecutionMaterialOutput { + pub id: String, + pub material_lot: String, + pub namespace: String, + pub display_id: String, + pub component: String, + pub material_kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub located_in: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub position: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub derived_from: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ExecutionPlanNode { + pub id: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub after: Vec, + #[serde(flatten)] + pub action: ExecutionPlanAction, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum ExecutionPlanAction { + Execute { + requirement: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + document: Option, + }, + MoveMaterial { + material: String, + from: String, + to: String, + instructions: String, + }, + Manual { + title: String, + instructions: String, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewedRunDocument { + pub path: String, + pub format: String, + pub sha256: String, +} + +fn require_sha256(label: &str, value: &str) -> Result<(), String> { + if value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + Ok(()) + } else { + Err(format!("{label} SHA-256 must be 64 hexadecimal characters")) + } +} + +fn require_relative_path(label: &str, value: &str) -> Result<(), String> { + let path = Path::new(value); + let invalid = value.is_empty() + || path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }); + if invalid { + Err(format!( + "{label} path '{value}' must be a non-empty relative path without '..'" + )) + } else { + Ok(()) + } +} + +fn validate_acyclic(nodes: &BTreeMap<&str, &ExecutionPlanNode>) -> Result<(), String> { + let mut indegree = nodes + .iter() + .map(|(id, node)| (*id, node.after.len())) + .collect::>(); + let mut dependents = BTreeMap::<&str, Vec<&str>>::new(); + for (id, node) in nodes { + for dependency in &node.after { + dependents.entry(dependency).or_default().push(id); + } + } + let mut ready = indegree + .iter() + .filter_map(|(id, degree)| (*degree == 0).then_some(*id)) + .collect::>(); + let mut visited = 0; + while let Some(id) = ready.pop_front() { + visited += 1; + for dependent in dependents.get(id).into_iter().flatten() { + let degree = indegree + .get_mut(dependent) + .expect("every dependent is a declared node"); + *degree -= 1; + if *degree == 0 { + ready.push_back(dependent); + } + } + } + if visited == nodes.len() { + Ok(()) + } else { + let cyclic = indegree + .into_iter() + .filter_map(|(id, degree)| (degree > 0).then_some(id)) + .collect::>() + .join(", "); + Err(format!( + "execution plan contains a dependency cycle among {cyclic}" + )) + } +} + /// One `lab.thermocycle-run.v0` document: a device-neutral thermal program -/// for one plate. The station's kind decides which instrument executes it; +/// for one plate. The exact Asset and adapter binding selects the executor; /// the document never names a vendor. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ThermocycleRunDocument { @@ -146,59 +668,20 @@ pub enum PlateReadMode { Luminescence { integration_seconds: f64 }, } -/// One `lab.workcell-run.v0` document: the coordination plan for one wave -/// of a multi-station build. Nodes execute in dependency order; every -/// physical plate movement is an explicit handoff node the operator -/// confirms. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct WorkcellRunDocument { - /// Always [`WORKCELL_RUN_FORMAT`]; readers reject any other value. - pub format: String, - pub stations: Vec, - pub nodes: Vec, -} - -/// One station as the coordination plan sees it: a name, the kind that -/// selects its executor, and where its program documents live relative to -/// the wave directory. +/// One reviewed semantic simulation step. +/// +/// This document records what a simulator is asked to model. It is never a hardware protocol and +/// never implies that a physical Asset has a compatible control path. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct WorkcellStation { - pub name: String, - /// The station kind string, e.g. `hamilton.star` or `inheco.odtc`. - pub kind: String, - pub program_dir: String, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct WorkcellNode { - /// Stable, human-readable identity, e.g. `assembly_run` or - /// `assembly_thermocycle.to-odtc-1`. +pub struct SimulationRunDocument { + /// Always [`SIMULATION_RUN_FORMAT`]. + pub format: String, pub id: String, - /// Node ids that must complete first. - #[serde(default)] - pub after: Vec, - #[serde(flatten)] - pub action: WorkcellAction, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(tag = "action", rename_all = "kebab-case")] -pub enum WorkcellAction { - /// Execute one station program document. - StationProgram { - station: String, - /// The document path relative to the wave directory. - document: String, - }, - /// A human moves labware between stations and confirms. - Handoff { - from: String, - to: String, - labware: String, - instructions: String, - }, - /// A human performs a step that is not a movement, and confirms. - Manual { title: String, instructions: String }, + pub title: String, + /// Exact capability-kind IRI this simulation models. + pub capability_kind: String, + /// Human-readable scope or assumptions reviewed with the simulation. + pub assumptions: Vec, } /// One replayable Hamilton STAR step: the id-less firmware frame and the @@ -241,6 +724,176 @@ pub struct StarRunDocument { mod tests { use super::*; + fn execution_plan() -> ExecutionPlanDocument { + ExecutionPlanDocument { + format: EXECUTION_PLAN_FORMAT.to_owned(), + inventory: ExecutionInventoryReference { + document: "inventory-source.ttl".to_owned(), + source_sha256: "a".repeat(64), + facility: "https://example.org/facility".to_owned(), + }, + requirements: vec![ExecutionRequirementBinding { + requirement_instance: "example::main/body[0]".to_owned(), + requirement_template: "example::main::body[0]".to_owned(), + capability_kind: "https://sbol.io/ns/capability#Incubation".to_owned(), + offering: "https://example.org/incubator/incubation".to_owned(), + asset: "https://example.org/incubator".to_owned(), + minimum_qualification: "https://sbol.io/ns/facility#Plannable".to_owned(), + observed_qualification: "https://sbol.io/ns/facility#Executable".to_owned(), + control_mode: "https://sbol.io/ns/facility#ReviewedFileControl".to_owned(), + parameters: Vec::new(), + adapter: Some(ExecutionAdapterBinding { + driver: "example.incubator".to_owned(), + profile_path: "adapters/incubator.toml".to_owned(), + profile_sha256: "b".repeat(64), + }), + }], + materials: Vec::new(), + outputs: Vec::new(), + lowerings: Vec::new(), + nodes: vec![ExecutionPlanNode { + id: "execute-0001".to_owned(), + after: Vec::new(), + action: ExecutionPlanAction::Execute { + requirement: "example::main/body[0]".to_owned(), + document: None, + }, + }], + } + } + + #[test] + fn an_execution_plan_round_trips_and_validates() { + let plan = execution_plan(); + plan.validate().unwrap(); + let text = serde_json::to_string_pretty(&plan).unwrap(); + assert_eq!( + serde_json::from_str::(&text).unwrap(), + plan + ); + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(EXECUTION_PLAN_FILE); + std::fs::write(&path, text).unwrap(); + assert_eq!(load_execution_plan(&path).unwrap(), plan); + } + + #[test] + fn execution_plan_validation_rejects_dangling_and_cyclic_dependencies() { + let mut dangling = execution_plan(); + dangling.nodes[0].after.push("missing".to_owned()); + assert!( + dangling + .validate() + .unwrap_err() + .contains("depends on unknown node") + ); + + let mut cyclic = execution_plan(); + cyclic.nodes.push(ExecutionPlanNode { + id: "execute-0002".to_owned(), + after: vec!["execute-0001".to_owned()], + action: ExecutionPlanAction::Manual { + title: "inspect".to_owned(), + instructions: "confirm".to_owned(), + }, + }); + cyclic.nodes[0].after.push("execute-0002".to_owned()); + assert!(cyclic.validate().unwrap_err().contains("dependency cycle")); + } + + #[test] + fn execution_plan_validation_checks_exact_references_and_digests() { + let mut plan = execution_plan(); + let ExecutionPlanAction::Execute { requirement, .. } = &mut plan.nodes[0].action else { + unreachable!() + }; + *requirement = "missing".to_owned(); + assert!(plan.validate().unwrap_err().contains("unknown requirement")); + + let mut plan = execution_plan(); + plan.inventory.source_sha256 = "not-a-digest".to_owned(); + assert!(plan.validate().unwrap_err().contains("SHA-256")); + } + + #[test] + fn execution_plan_validation_freezes_whole_program_adapter_lowerings() { + let mut plan = execution_plan(); + let adapter = plan.requirements[0].adapter.clone().unwrap(); + plan.lowerings.push(ExecutionLoweringBundle { + id: "example-incubator-a1b2c3d4e5f6".to_owned(), + asset: "https://example.org/incubator".to_owned(), + adapter, + requirements: vec!["example::main/body[0]".to_owned()], + artifacts: vec![ReviewedLoweringArtifact { + path: "lowerings/incubator/run.json".to_owned(), + media_type: "application/json".to_owned(), + sha256: "c".repeat(64), + role: ReviewedLoweringArtifactRole::DeviceProtocol, + format: Some("example.incubator-run.v1".to_owned()), + }], + }); + plan.validate().unwrap(); + + let mut wrong_asset = plan.clone(); + wrong_asset.lowerings[0].asset = "https://example.org/other".to_owned(); + assert!( + wrong_asset + .validate() + .unwrap_err() + .contains("but requirement") + ); + + let mut missing_format = plan; + missing_format.lowerings[0].artifacts[0].format = None; + assert!( + missing_format + .validate() + .unwrap_err() + .contains("no run-document format") + ); + } + + #[test] + fn execution_plan_validation_freezes_output_material_identity_and_lineage() { + let mut plan = execution_plan(); + plan.materials.push(ExecutionMaterialBinding { + id: "input".to_owned(), + component: "https://example.org/design".to_owned(), + material_lot: "https://example.org/input".to_owned(), + }); + plan.outputs.push(ExecutionMaterialOutput { + id: "output".to_owned(), + material_lot: "https://example.org/results/output".to_owned(), + namespace: "https://example.org/results".to_owned(), + display_id: "output".to_owned(), + component: "https://example.org/design".to_owned(), + material_kind: "https://sbol.io/ns/inventory#DnaSample".to_owned(), + located_in: None, + position: None, + derived_from: vec!["input".to_owned()], + }); + plan.validate().unwrap(); + + let mut wrong_identity = plan.clone(); + wrong_identity.outputs[0].material_lot = "https://example.org/results/other".to_owned(); + assert!( + wrong_identity + .validate() + .unwrap_err() + .contains("namespace/display_id") + ); + + let mut unknown_source = plan; + unknown_source.outputs[0].derived_from = vec!["missing".to_owned()]; + assert!( + unknown_source + .validate() + .unwrap_err() + .contains("unknown input material") + ); + } + #[test] fn a_star_run_document_round_trips_through_json() { let document = StarRunDocument { @@ -265,6 +918,22 @@ mod tests { assert_eq!(back, document, "emitter and runner read the same schema"); } + #[test] + fn a_capability_simulation_document_round_trips() { + let document = SimulationRunDocument { + format: SIMULATION_RUN_FORMAT.to_owned(), + id: "growth".to_owned(), + title: "Simulate plate growth".to_owned(), + capability_kind: "https://sbol.io/ns/capability#Incubation".to_owned(), + assumptions: vec!["No physical hardware is contacted.".to_owned()], + }; + let text = serde_json::to_string_pretty(&document).unwrap(); + assert_eq!( + serde_json::from_str::(&text).unwrap(), + document + ); + } + #[test] fn a_document_without_manual_steps_parses_with_an_empty_list() { let text = r#"{ @@ -295,16 +964,4 @@ mod tests { "the error names the expected format: {error}" ); } - - #[test] - fn the_workcell_plan_loader_reads_from_its_well_known_file_name() { - let directory = tempfile::tempdir().expect("the test directory is creatable"); - std::fs::write( - directory.path().join(WORKCELL_PLAN_FILE), - r#"{ "format": "lab.workcell-run.v0", "stations": [], "nodes": [] }"#, - ) - .expect("the fixture writes"); - let plan = load_workcell_plan(directory.path()).expect("the plan loads"); - assert!(plan.nodes.is_empty(), "the empty plan round-trips"); - } } diff --git a/crates/lab-runtime/Cargo.toml b/crates/lab-runtime/Cargo.toml index a19caeb..1b19a2f 100644 --- a/crates/lab-runtime/Cargo.toml +++ b/crates/lab-runtime/Cargo.toml @@ -16,10 +16,15 @@ hardware = ["hamilton-star/usb"] anyhow = { workspace = true } hamilton-star = { workspace = true } lab-instruments = { workspace = true } +lab-inventory = { workspace = true } lab-runfmt = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } +sbol-inventory = { workspace = true } +sbol3 = { workspace = true } thiserror = { workspace = true } +time = { workspace = true } [dev-dependencies] tempfile = "3" diff --git a/crates/lab-runtime/src/device_executors.rs b/crates/lab-runtime/src/device_executors.rs new file mode 100644 index 0000000..4753217 --- /dev/null +++ b/crates/lab-runtime/src/device_executors.rs @@ -0,0 +1,197 @@ +//! Live executors for reviewed device documents. +//! +//! Construction is inert. USB and network sessions open only from [`DocumentExecutor::execute`], +//! after the complete facility plan has passed preflight, the registry has resolved every exact +//! binding, and the operator has accepted the pre-run gate. + +#[cfg(feature = "hardware")] +use std::net::SocketAddr; + +use anyhow::Result; +#[cfg(feature = "hardware")] +use anyhow::{Context, bail}; +#[cfg(feature = "hardware")] +use lab_instruments::Thermocycler as _; + +use crate::events::EventSink; +#[cfg(feature = "hardware")] +use crate::events::{ProgramExtent, RunEvent}; +use crate::execution::{DocumentExecutor, LoadedReviewedDocument}; + +/// A no-hardware executor for an already validated reviewed document. +/// +/// Semantic behavior belongs to the document producer or a future domain simulator. This +/// executor deliberately performs no device I/O and exists only in an explicitly selected +/// simulation registry. +#[derive(Default)] +pub struct ReviewedDocumentSimulationExecutor; + +impl DocumentExecutor for ReviewedDocumentSimulationExecutor { + fn execute( + &mut self, + _loaded: &LoadedReviewedDocument, + _events: &mut dyn EventSink, + ) -> Result<()> { + Ok(()) + } +} + +/// Replays `lab.star-run.v0` on the exact Asset binding registered by the caller. +#[cfg(feature = "hardware")] +pub struct HamiltonStarExecutor { + asset: String, + autoload_park_track: Option, + session: Option, +} + +#[cfg(feature = "hardware")] +impl HamiltonStarExecutor { + pub fn new(asset: impl Into, autoload_park_track: Option) -> Self { + Self { + asset: asset.into(), + autoload_park_track, + session: None, + } + } + + fn session(&mut self, events: &mut dyn EventSink) -> Result<&hamilton_star::Star> { + if self.session.is_none() { + events.emit(RunEvent::Connecting { + asset: self.asset.clone(), + detail: "the first Hamilton STAR on USB".to_owned(), + }); + self.session = Some(crate::star::open_usb_star(self.autoload_park_track)?); + events.emit(RunEvent::Connected { + asset: self.asset.clone(), + }); + } + Ok(self + .session + .as_ref() + .expect("the Hamilton STAR session was just opened")) + } +} + +#[cfg(feature = "hardware")] +impl DocumentExecutor for HamiltonStarExecutor { + fn execute( + &mut self, + loaded: &LoadedReviewedDocument, + events: &mut dyn EventSink, + ) -> Result<()> { + let LoadedReviewedDocument::Star { document, commands } = loaded else { + bail!("the Hamilton STAR executor received a non-STAR document"); + }; + events.emit(RunEvent::ProgramStarted { + asset: self.asset.clone(), + title: document.title.clone(), + extent: ProgramExtent::Frames { + frames: commands.len(), + }, + }); + let asset = self.asset.clone(); + let session = self.session(events)?; + for (index, (step, command)) in document.steps.iter().zip(commands).enumerate() { + events.emit(RunEvent::Frame { + asset: asset.clone(), + index: index + 1, + description: step.description.clone(), + }); + if let Err(error) = crate::star::execute_frame(session, command) { + bail!( + "firmware error at frame {}: {error}; channels were retracted to Z-safety", + index + 1 + ); + } + } + Ok(()) + } +} + +/// Runs `lab.thermocycle-run.v0` on one exact network-addressed Inheco ODTC Asset. +#[cfg(feature = "hardware")] +pub struct OdtcExecutor { + asset: String, + address: SocketAddr, + session: Option, +} + +#[cfg(feature = "hardware")] +impl OdtcExecutor { + pub fn new(asset: impl Into, address: SocketAddr) -> Self { + Self { + asset: asset.into(), + address, + session: None, + } + } + + fn session(&mut self, events: &mut dyn EventSink) -> Result<&mut lab_instruments::OdtcStation> { + if self.session.is_none() { + events.emit(RunEvent::Connecting { + asset: self.asset.clone(), + detail: self.address.to_string(), + }); + self.session = Some( + lab_instruments::OdtcStation::connect(self.address).with_context(|| { + format!( + "the Inheco ODTC Asset '{}' did not answer at {}", + self.asset, self.address + ) + })?, + ); + events.emit(RunEvent::Connected { + asset: self.asset.clone(), + }); + } + Ok(self + .session + .as_mut() + .expect("the Inheco ODTC session was just opened")) + } +} + +#[cfg(feature = "hardware")] +impl DocumentExecutor for OdtcExecutor { + fn execute( + &mut self, + loaded: &LoadedReviewedDocument, + events: &mut dyn EventSink, + ) -> Result<()> { + let LoadedReviewedDocument::Thermocycle(document) = loaded else { + bail!("the Inheco ODTC executor received a non-thermocycle document"); + }; + events.emit(RunEvent::ProgramStarted { + asset: self.asset.clone(), + title: document.title.clone(), + extent: ProgramExtent::Plateaus { + plateaus: document.profile.total_steps(), + final_hold_celsius: document.final_hold_celsius, + }, + }); + let asset = self.asset.clone(); + let session = self.session(events)?; + let handle = session + .run_profile(&document.profile) + .with_context(|| format!("could not start '{}' on {asset}", document.id))?; + events.emit(RunEvent::ThermalRunning { + asset: asset.clone(), + }); + session + .await_completion(handle) + .with_context(|| format!("'{}' did not complete on {asset}", document.id))?; + for warning in session.take_warnings() { + events.emit(RunEvent::ThermalWarning { + asset: asset.clone(), + warning, + }); + } + if let Some(celsius) = document.final_hold_celsius { + session + .hold_block(celsius, None) + .with_context(|| format!("could not hold {celsius} C on {asset}"))?; + events.emit(RunEvent::ThermalHold { asset, celsius }); + } + Ok(()) + } +} diff --git a/crates/lab-runtime/src/events.rs b/crates/lab-runtime/src/events.rs index a3cfa55..1d67692 100644 --- a/crates/lab-runtime/src/events.rs +++ b/crates/lab-runtime/src/events.rs @@ -1,7 +1,7 @@ //! The event port: everything a live or dry run has to say goes through one //! sink. The CLI's sink turns these facts into operator-facing narration. -/// One observable moment in a workcell run. +/// One observable moment in a reviewed facility run. #[derive(Clone, Debug, PartialEq)] pub enum RunEvent { /// The walk is about to start `pending` nodes, skipping `completed`. @@ -10,11 +10,11 @@ pub enum RunEvent { completed: usize, }, Connecting { - station: String, + asset: String, detail: String, }, Connected { - station: String, + asset: String, }, NodeStarted { id: String, @@ -25,36 +25,43 @@ pub enum RunEvent { NodeCompleted { id: String, }, - /// A station program began: a STAR frame sequence or a thermal profile. + /// One reviewed child document is about to execute through its exact Asset/adapter binding. + DocumentStarted { + asset: String, + driver: String, + format: String, + title: String, + }, + /// An Asset program began: a STAR frame sequence or a thermal profile. ProgramStarted { - station: String, + asset: String, title: String, extent: ProgramExtent, }, /// One STAR frame is about to execute. Frame { - station: String, + asset: String, index: usize, description: String, }, - /// The thermal profile is running to completion on its station. + /// The thermal profile is running to completion on its bound Asset. ThermalRunning { - station: String, + asset: String, }, ThermalWarning { - station: String, + asset: String, warning: String, }, /// The block holds a temperature until retrieval. ThermalHold { - station: String, + asset: String, celsius: f64, }, DoorOpened { - station: String, + asset: String, }, DoorClosed { - station: String, + asset: String, }, /// The operator is needed, starting now. AttentionRequired { @@ -65,7 +72,7 @@ pub enum RunEvent { AttentionReleased { node: String, }, - /// Labware physically moved between stations. + /// Labware moved between exact facility locations or Assets. LabwareMoved { labware: String, from: String, @@ -73,7 +80,7 @@ pub enum RunEvent { }, } -/// How large a station program is, in the unit the station thinks in. +/// How large a device program is, in the unit the device thinks in. #[derive(Clone, Debug, PartialEq)] pub enum ProgramExtent { Frames { diff --git a/crates/lab-runtime/src/execution.rs b/crates/lab-runtime/src/execution.rs new file mode 100644 index 0000000..b87f96a --- /dev/null +++ b/crates/lab-runtime/src/execution.rs @@ -0,0 +1,1645 @@ +//! Eager preflight for facility-wide reviewed execution plans. +//! +//! Loading is deliberately more than JSON parsing. It validates the exact inventory graph, +//! checks every frozen profile and child-document digest, projects every catalog binding back +//! onto the selected facility, validates every device document, and computes a deterministic +//! topological walk. A live runner receives only a [`LoadedExecutionPlan`], so it cannot discover +//! a bad document after an instrument has already moved. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use hamilton_star::RawCommand; +use lab_inventory::{FacilityScalarValue, InventorySnapshot}; +use lab_runfmt::{ + EXECUTION_PLAN_FILE, EXECUTION_PLAN_FORMAT, ExecutionParameterValue, ExecutionPlanAction, + ExecutionPlanDocument, ExecutionPlanNode, ExecutionRequirementBinding, PLATE_READ_FORMAT, + PlateReadDocument, ReviewedLoweringArtifactRole, SIMULATION_RUN_FORMAT, STAR_RUN_FORMAT, + SimulationRunDocument, StarRunDocument, THERMOCYCLE_RUN_FORMAT, ThermocycleRunDocument, +}; +use sbol3::{DisplayId, Iri, Namespace, Resource}; +use sha2::{Digest, Sha256}; + +use crate::clock::Clock; +use crate::events::{EventSink, RunEvent}; +use crate::ledger::{ExecutionLedger, LEDGER_FILE, LedgerEvent}; +use crate::mode::ExecutionMode; +use crate::operator::{ConfirmKind, Operator}; + +/// One facility-wide plan after every frozen input and catalog binding has passed preflight. +#[derive(Debug)] +pub struct LoadedExecutionPlan { + pub directory: PathBuf, + pub plan: ExecutionPlanDocument, + /// SHA-256 of the exact reviewed `plan.execution.json` bytes. + pub plan_sha256: String, + pub inventory: InventorySnapshot, + /// Nodes in deterministic topological order, independent of their serialized order. + pub nodes: Vec, +} + +impl LoadedExecutionPlan { + /// Reasons this valid reviewed plan cannot run in the requested mode. + /// Planning-only plans remain useful and can still be rendered as dry runs. + pub fn readiness_issues(&self, mode: ExecutionMode) -> Vec { + let mut issues = Vec::new(); + let minimum = match mode { + ExecutionMode::Simulation => sbol_inventory::vocabulary::Qualification::Simulatable, + ExecutionMode::Live => sbol_inventory::vocabulary::Qualification::Executable, + }; + if mode == ExecutionMode::Simulation && !self.plan.outputs.is_empty() { + issues.push( + "simulation plans cannot mint physical output MaterialLots; remove plan outputs or execute the reviewed plan live" + .to_owned(), + ); + } + for node in &self.nodes { + let LoadedExecutionAction::Execute { + requirement, + document, + } = &node.action + else { + continue; + }; + let qualification = sbol_inventory::vocabulary::Qualification::try_from( + requirement.observed_qualification.as_str(), + ); + if !qualification.is_ok_and(|value| value >= minimum) { + issues.push(format!( + "node '{}' is bound only at qualification '{}', below '{}' for {}", + node.id, + requirement.observed_qualification, + minimum.iri(), + mode.as_str() + )); + } + if requirement.adapter.is_none() { + issues.push(format!("node '{}' has no frozen runtime adapter", node.id)); + } + if document.is_none() { + issues.push(format!("node '{}' has no reviewed run document", node.id)); + } + } + issues + } + + pub fn is_ready(&self, mode: ExecutionMode) -> bool { + self.readiness_issues(mode).is_empty() + } +} + +#[derive(Debug)] +pub struct LoadedExecutionNode { + pub id: String, + pub after: Vec, + pub action: LoadedExecutionAction, +} + +#[derive(Debug)] +pub enum LoadedExecutionAction { + Execute { + requirement: Box, + document: Option, + }, + MoveMaterial { + material: String, + from: String, + to: String, + instructions: String, + }, + Manual { + title: String, + instructions: String, + }, +} + +#[derive(Debug)] +pub enum LoadedReviewedDocument { + Star { + document: StarRunDocument, + commands: Vec, + }, + Thermocycle(ThermocycleRunDocument), + PlateRead(PlateReadDocument), + Simulation(SimulationRunDocument), +} + +impl LoadedReviewedDocument { + pub fn format(&self) -> &'static str { + match self { + Self::Star { .. } => STAR_RUN_FORMAT, + Self::Thermocycle(_) => THERMOCYCLE_RUN_FORMAT, + Self::PlateRead(_) => PLATE_READ_FORMAT, + Self::Simulation(_) => SIMULATION_RUN_FORMAT, + } + } + + pub fn title(&self) -> &str { + match self { + Self::Star { document, .. } => &document.title, + Self::Thermocycle(document) => &document.title, + Self::PlateRead(document) => &document.title, + Self::Simulation(document) => &document.title, + } + } +} + +/// An implementation of one exact reviewed-document binding. +pub trait DocumentExecutor { + fn execute( + &mut self, + document: &LoadedReviewedDocument, + events: &mut dyn EventSink, + ) -> Result<()>; +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct ExecutorKey { + asset: String, + driver: String, + format: String, +} + +/// Runtime executors keyed by the frozen Asset IRI, adapter ID, and document format. +/// There is no lookup by manufacturer, model, capability kind, or nearest match. +#[derive(Default)] +pub struct ExecutorRegistry { + executors: BTreeMap>, +} + +impl ExecutorRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn register( + &mut self, + asset: impl Into, + driver: impl Into, + format: impl Into, + executor: Box, + ) -> Result<()> { + let key = ExecutorKey { + asset: asset.into(), + driver: driver.into(), + format: format.into(), + }; + if key.asset.is_empty() || key.driver.is_empty() || key.format.is_empty() { + bail!("an executor key requires a non-empty Asset IRI, adapter ID, and format"); + } + if self.executors.insert(key.clone(), executor).is_some() { + bail!( + "an executor is already registered for asset '{}', adapter '{}', format '{}'", + key.asset, + key.driver, + key.format + ); + } + Ok(()) + } + + fn contains(&self, asset: &str, driver: &str, format: &str) -> bool { + self.executors.contains_key(&ExecutorKey { + asset: asset.to_owned(), + driver: driver.to_owned(), + format: format.to_owned(), + }) + } + + fn executor_mut( + &mut self, + asset: &str, + driver: &str, + format: &str, + ) -> Option<&mut (dyn DocumentExecutor + '_)> { + let key = ExecutorKey { + asset: asset.to_owned(), + driver: driver.to_owned(), + format: format.to_owned(), + }; + match self.executors.get_mut(&key) { + Some(executor) => Some(executor.as_mut()), + None => None, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ExecutionRunConfig { + pub assume_yes: bool, + pub resume: bool, + pub mode: ExecutionMode, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum ExecutionOutcome { + Completed { + executed: usize, + skipped: usize, + started_at_unix_seconds: u64, + ended_at_unix_seconds: u64, + }, + Cancelled, + Declined { + node: String, + }, + Failed { + node: String, + error: String, + }, +} + +/// Renders the fully preflighted facility walk without requiring runtime connectors. +pub fn render_execution_dry_run(loaded: &LoadedExecutionPlan) -> String { + use std::fmt::Write as _; + + let issues = loaded.readiness_issues(ExecutionMode::Live); + let mut text = String::new(); + let _ = writeln!( + text, + "dry run: {} facility node(s), all frozen inputs validated", + loaded.nodes.len() + ); + if !issues.is_empty() { + let _ = writeln!(text, "planning-only bindings:"); + for issue in issues { + let _ = writeln!(text, " - {issue}"); + } + } + if !loaded.plan.lowerings.is_empty() { + let _ = writeln!(text, "reviewed adapter lowerings:"); + for lowering in &loaded.plan.lowerings { + let protocols = lowering + .artifacts + .iter() + .filter(|artifact| artifact.role == ReviewedLoweringArtifactRole::DeviceProtocol) + .collect::>(); + let _ = writeln!( + text, + " - {}: {} device protocol(s) jointly cover {} requirement(s) on {} through {}", + lowering.id, + protocols.len(), + lowering.requirements.len(), + lowering.asset, + lowering.adapter.driver + ); + for protocol in protocols { + let _ = writeln!( + text, + " {} ({})", + protocol.path, + protocol.format.as_deref().unwrap_or("unknown format") + ); + } + } + } + for (index, node) in loaded.nodes.iter().enumerate() { + match &node.action { + LoadedExecutionAction::Execute { + requirement, + document, + } => { + let description = document.as_ref().map_or_else( + || "no reviewed run document".to_owned(), + |document| format!("{} ({})", document.title(), document.format()), + ); + let adapter = requirement + .adapter + .as_ref() + .map_or("no runtime adapter", |adapter| adapter.driver.as_str()); + let _ = writeln!( + text, + "\n[{}] {} - {} on {} through {}: {}", + index + 1, + node.id, + requirement.capability_kind, + requirement.asset, + adapter, + description + ); + } + LoadedExecutionAction::MoveMaterial { + material, + from, + to, + instructions, + } => { + let _ = writeln!( + text, + "\n[{}] {} - move {} from {} to {}: {}", + index + 1, + node.id, + material, + from, + to, + instructions + ); + } + LoadedExecutionAction::Manual { + title, + instructions, + } => { + let _ = writeln!( + text, + "\n[{}] {} - by hand: {}: {}", + index + 1, + node.id, + title, + instructions + ); + } + } + } + text +} + +/// Executes a preflighted plan without ever re-querying the inventory or changing a binding. +#[allow(clippy::too_many_arguments)] +pub fn run_execution_plan( + loaded: &LoadedExecutionPlan, + config: ExecutionRunConfig, + registry: &mut ExecutorRegistry, + operator: &mut dyn Operator, + events: &mut dyn EventSink, + clock: &dyn Clock, +) -> Result { + let mut readiness = loaded.readiness_issues(config.mode); + for node in &loaded.nodes { + let LoadedExecutionAction::Execute { + requirement, + document: Some(document), + } = &node.action + else { + continue; + }; + let Some(adapter) = &requirement.adapter else { + continue; + }; + if !registry.contains(&requirement.asset, &adapter.driver, document.format()) { + readiness.push(format!( + "node '{}' has no registered executor for asset '{}', adapter '{}', format '{}'", + node.id, + requirement.asset, + adapter.driver, + document.format() + )); + } + } + if !readiness.is_empty() { + bail!( + "reviewed plan is not ready for {}:\n - {}", + config.mode.as_str(), + readiness.join("\n - ") + ); + } + + let valid_nodes = loaded + .nodes + .iter() + .map(|node| node.id.clone()) + .collect::>(); + let inventory_sha256 = loaded.inventory.source_sha256(); + let mut ledger = if config.resume { + Some(ExecutionLedger::resume( + &loaded.directory, + &loaded.plan_sha256, + inventory_sha256, + valid_nodes.clone(), + config.mode, + )?) + } else { + let path = loaded.directory.join(LEDGER_FILE); + if path.exists() { + bail!( + "{} already exists; resume the reviewed plan instead of replacing durable {} state", + path.display(), + config.mode.as_str() + ); + } + None + }; + let completed = ledger + .as_ref() + .map(|ledger| ledger.completed_nodes().clone()) + .unwrap_or_default(); + let pending = loaded.nodes.len() - completed.len(); + events.emit(RunEvent::Planned { + pending, + completed: completed.len(), + }); + if !config.assume_yes + && !operator.confirm( + ConfirmKind::PreRun, + match config.mode { + ExecutionMode::Simulation => { + "proceed with the exact reviewed facility simulation? [y/N] " + } + ExecutionMode::Live => { + "proceed with the exact reviewed facility plan? Devices may move. [y/N] " + } + }, + )? + { + return Ok(ExecutionOutcome::Cancelled); + } + if ledger.is_none() { + ledger = Some(ExecutionLedger::create( + &loaded.directory, + &loaded.plan_sha256, + inventory_sha256, + valid_nodes, + config.mode, + clock, + )?); + } + let ledger = ledger.as_mut().expect("the pre-run gate opened a ledger"); + + let mut executed = 0usize; + for node in &loaded.nodes { + if completed.contains(&node.id) { + events.emit(RunEvent::NodeSkipped { + id: node.id.clone(), + }); + continue; + } + ledger.append(&node.id, LedgerEvent::Started, clock)?; + events.emit(RunEvent::NodeStarted { + id: node.id.clone(), + }); + match execute_execution_node(node, registry, operator, events) { + Ok(NodeExecution::Done) => { + ledger.append(&node.id, LedgerEvent::Completed, clock)?; + events.emit(RunEvent::NodeCompleted { + id: node.id.clone(), + }); + executed += 1; + } + Ok(NodeExecution::Declined) => { + ledger.append(&node.id, LedgerEvent::Failed, clock)?; + return Ok(ExecutionOutcome::Declined { + node: node.id.clone(), + }); + } + Err(error) => { + ledger.append(&node.id, LedgerEvent::Failed, clock)?; + return Ok(ExecutionOutcome::Failed { + node: node.id.clone(), + error: format!("{error:#}"), + }); + } + } + } + let ended_at_unix_seconds = ledger + .last_completed_at_unix_seconds() + .unwrap_or_else(|| clock.now_unix()); + Ok(ExecutionOutcome::Completed { + executed, + skipped: completed.len(), + started_at_unix_seconds: ledger.started_at_unix_seconds(), + ended_at_unix_seconds, + }) +} + +enum NodeExecution { + Done, + Declined, +} + +fn execute_execution_node( + node: &LoadedExecutionNode, + registry: &mut ExecutorRegistry, + operator: &mut dyn Operator, + events: &mut dyn EventSink, +) -> Result { + match &node.action { + LoadedExecutionAction::Execute { + requirement, + document: Some(document), + } => { + let adapter = requirement + .adapter + .as_ref() + .expect("runtime readiness requires an adapter"); + events.emit(RunEvent::DocumentStarted { + asset: requirement.asset.clone(), + driver: adapter.driver.clone(), + format: document.format().to_owned(), + title: document.title().to_owned(), + }); + registry + .executor_mut(&requirement.asset, &adapter.driver, document.format()) + .expect("runtime readiness resolved the exact executor") + .execute(document, events) + .with_context(|| { + format!( + "asset '{}' failed through adapter '{}' for node '{}'", + requirement.asset, adapter.driver, node.id + ) + })?; + Ok(NodeExecution::Done) + } + LoadedExecutionAction::Execute { document: None, .. } => { + unreachable!("runtime readiness rejects planning-only execute nodes") + } + LoadedExecutionAction::MoveMaterial { + material, + from, + to, + instructions, + } => { + let prompt = format!("{instructions} ({material}: {from} -> {to})"); + events.emit(RunEvent::AttentionRequired { + node: node.id.clone(), + prompt, + }); + let confirmed = operator.confirm( + ConfirmKind::Handoff, + "done, and the facility matches the reviewed plan? Continue [y/N] ", + )?; + events.emit(RunEvent::AttentionReleased { + node: node.id.clone(), + }); + if !confirmed { + return Ok(NodeExecution::Declined); + } + events.emit(RunEvent::LabwareMoved { + labware: material.clone(), + from: from.clone(), + to: to.clone(), + }); + Ok(NodeExecution::Done) + } + LoadedExecutionAction::Manual { + title, + instructions, + } => { + events.emit(RunEvent::AttentionRequired { + node: node.id.clone(), + prompt: format!("{title}: {instructions}"), + }); + let confirmed = operator.confirm( + ConfirmKind::Manual, + "done, and the facility matches the reviewed plan? Continue [y/N] ", + )?; + events.emit(RunEvent::AttentionReleased { + node: node.id.clone(), + }); + if confirmed { + Ok(NodeExecution::Done) + } else { + Ok(NodeExecution::Declined) + } + } + } +} + +/// Loads and eagerly validates the well-known reviewed plan in `directory`. +pub fn load_execution_directory(directory: &Path) -> Result { + let directory = fs::canonicalize(directory).with_context(|| { + format!( + "failed to resolve execution directory {}", + directory.display() + ) + })?; + let plan_path = directory.join(EXECUTION_PLAN_FILE); + let plan_bytes = fs::read(&plan_path) + .with_context(|| format!("failed to read reviewed plan {}", plan_path.display()))?; + let plan_sha256 = sha256_hex(&plan_bytes); + let plan: ExecutionPlanDocument = serde_json::from_slice(&plan_bytes) + .with_context(|| format!("{} is not a valid execution plan", plan_path.display()))?; + if plan.format != EXECUTION_PLAN_FORMAT { + bail!( + "{} declares format '{}', expected '{}'", + plan_path.display(), + plan.format, + EXECUTION_PLAN_FORMAT + ); + } + plan.validate() + .map_err(anyhow::Error::msg) + .with_context(|| format!("{} is not a valid execution plan", plan_path.display()))?; + + let inventory = InventorySnapshot::load( + &directory, + &plan.inventory.document, + Some(&plan.inventory.facility), + ) + .with_context(|| { + format!( + "failed to validate frozen inventory source '{}'", + plan.inventory.document + ) + })?; + if inventory.source_sha256() != plan.inventory.source_sha256 { + bail!( + "frozen inventory source '{}' has SHA-256 {}, but the reviewed plan requires {}", + plan.inventory.document, + inventory.source_sha256(), + plan.inventory.source_sha256 + ); + } + + validate_catalog_bindings(&plan, &inventory)?; + for requirement in &plan.requirements { + if let Some(adapter) = &requirement.adapter { + read_frozen_input( + &directory, + &adapter.profile_path, + &adapter.profile_sha256, + &format!( + "adapter profile for requirement '{}'", + requirement.requirement_instance + ), + )?; + } + } + for lowering in &plan.lowerings { + for artifact in &lowering.artifacts { + read_frozen_input( + &directory, + &artifact.path, + &artifact.sha256, + &format!( + "reviewed artifact '{}' for adapter lowering '{}'", + artifact.path, lowering.id + ), + )?; + } + } + + let requirements = plan + .requirements + .iter() + .map(|requirement| (requirement.requirement_instance.as_str(), requirement)) + .collect::>(); + let ordered = topological_nodes(&plan.nodes); + let mut nodes = Vec::with_capacity(ordered.len()); + for node in ordered { + let action = match &node.action { + ExecutionPlanAction::Execute { + requirement, + document, + } => { + let binding = requirements + .get(requirement.as_str()) + .expect("execution-plan validation resolved every requirement"); + let loaded = match document { + Some(document) => { + let adapter = binding.adapter.as_ref().with_context(|| { + format!( + "execute node '{}' has a reviewed document but no adapter binding", + node.id + ) + })?; + let bytes = read_frozen_input( + &directory, + &document.path, + &document.sha256, + &format!("reviewed run document for node '{}'", node.id), + )?; + Some(load_reviewed_document( + &adapter.driver, + &document.format, + &binding.capability_kind, + &bytes, + &directory.join(&document.path), + )?) + } + None => None, + }; + LoadedExecutionAction::Execute { + requirement: Box::new((*binding).clone()), + document: loaded, + } + } + ExecutionPlanAction::MoveMaterial { + material, + from, + to, + instructions, + } => LoadedExecutionAction::MoveMaterial { + material: material.clone(), + from: from.clone(), + to: to.clone(), + instructions: instructions.clone(), + }, + ExecutionPlanAction::Manual { + title, + instructions, + } => LoadedExecutionAction::Manual { + title: title.clone(), + instructions: instructions.clone(), + }, + }; + nodes.push(LoadedExecutionNode { + id: node.id.clone(), + after: node.after.clone(), + action, + }); + } + + Ok(LoadedExecutionPlan { + directory, + plan, + plan_sha256, + inventory, + nodes, + }) +} + +fn validate_catalog_bindings( + plan: &ExecutionPlanDocument, + inventory: &InventorySnapshot, +) -> Result<()> { + for binding in &plan.requirements { + let asset = inventory.facility_asset(&binding.asset).with_context(|| { + format!( + "requirement '{}' binds invalid asset '{}'", + binding.requirement_instance, binding.asset + ) + })?; + let offering = asset + .offerings + .iter() + .find(|offering| offering.identity.as_str() == binding.offering) + .with_context(|| { + format!( + "requirement '{}' binds offering '{}', which asset '{}' does not own", + binding.requirement_instance, binding.offering, binding.asset + ) + })?; + if !offering.effectively_active { + bail!( + "requirement '{}' binds inactive offering '{}'", + binding.requirement_instance, + binding.offering + ); + } + if offering.capability_kind.as_str() != binding.capability_kind { + bail!( + "requirement '{}' records capability '{}', but offering '{}' exposes '{}'", + binding.requirement_instance, + binding.capability_kind, + binding.offering, + offering.capability_kind + ); + } + if offering.qualification.iri() != binding.observed_qualification { + bail!( + "requirement '{}' records qualification '{}', but offering '{}' has '{}'", + binding.requirement_instance, + binding.observed_qualification, + binding.offering, + offering.qualification.iri() + ); + } + let minimum = sbol_inventory::vocabulary::Qualification::try_from( + binding.minimum_qualification.as_str(), + ) + .with_context(|| { + format!( + "requirement '{}' has an unknown minimum qualification", + binding.requirement_instance + ) + })?; + if offering.qualification < minimum { + bail!( + "requirement '{}' needs qualification '{}' but offering '{}' has only '{}'", + binding.requirement_instance, + minimum.iri(), + binding.offering, + offering.qualification.iri() + ); + } + if offering.control_mode.iri() != binding.control_mode { + bail!( + "requirement '{}' records control mode '{}', but offering '{}' has '{}'", + binding.requirement_instance, + binding.control_mode, + binding.offering, + offering.control_mode.iri() + ); + } + for parameter in &binding.parameters { + let observed = offering + .parameters + .iter() + .find(|candidate| candidate.identity.as_str() == parameter.offering_parameter) + .with_context(|| { + format!( + "requirement '{}' binds missing offering parameter '{}'", + binding.requirement_instance, parameter.offering_parameter + ) + })?; + if parameter.relation != "exact" + || observed.property_kind.as_str() != parameter.property_kind + || !scalar_equal(¶meter.observed, &observed.value) + || observed.unit.as_ref().map(|unit| unit.as_str()) + != parameter.observed_unit.as_deref() + { + bail!( + "requirement '{}' has a parameter binding inconsistent with '{}'", + binding.requirement_instance, + parameter.offering_parameter + ); + } + } + } + + let lots = inventory.active_material_lots()?; + for material in &plan.materials { + let component = Iri::new(material.component.clone()) + .with_context(|| format!("material '{}' has an invalid Component IRI", material.id))?; + if !lots + .candidates(&component) + .iter() + .any(|lot| lot.as_str() == material.material_lot) + { + bail!( + "material '{}' binds lot '{}', which is not an active realization of '{}' in the selected facility", + material.id, + material.material_lot, + material.component + ); + } + } + validate_output_bindings(plan, inventory)?; + Ok(()) +} + +fn validate_output_bindings( + plan: &ExecutionPlanDocument, + inventory: &InventorySnapshot, +) -> Result<()> { + let document = inventory.document(); + let selected_facility = Resource::Iri(inventory.facility().clone()); + let mut planned_occupancy = BTreeSet::new(); + for output in &plan.outputs { + let namespace = Namespace::new(output.namespace.clone()).with_context(|| { + format!( + "output material '{}' has an invalid SBOL namespace", + output.id + ) + })?; + DisplayId::new(output.display_id.clone()).with_context(|| { + format!( + "output material '{}' has an invalid SBOL displayId", + output.id + ) + })?; + let identity = Resource::Iri(Iri::new(output.material_lot.clone()).with_context(|| { + format!( + "output material '{}' has an invalid MaterialLot IRI", + output.id + ) + })?); + if identity.to_string() != format!("{}/{}", namespace.as_str(), output.display_id) { + bail!( + "output material '{}' identity is inconsistent with its namespace and displayId", + output.id + ); + } + if document.as_sbol_document().get(&identity).is_some() { + bail!( + "output MaterialLot '{}' already exists in the reviewed inventory", + output.material_lot + ); + } + Iri::new(output.material_kind.clone()).with_context(|| { + format!( + "output material '{}' has an invalid material-kind IRI", + output.id + ) + })?; + let component = Resource::Iri(Iri::new(output.component.clone()).with_context(|| { + format!( + "output material '{}' has an invalid Component IRI", + output.id + ) + })?); + let component_object = document + .as_sbol_document() + .get(&component) + .with_context(|| { + format!( + "output material '{}' references missing Component '{}'", + output.id, output.component + ) + })?; + if !component_object + .rdf_types() + .iter() + .any(|kind| kind.as_str() == sbol_inventory::vocabulary::SBOL_COMPONENT) + { + bail!( + "output material '{}' built identity '{}' is not an SBOL Component", + output.id, + output.component + ); + } + + let Some(location) = output.located_in.as_ref() else { + if output.position.is_some() { + bail!( + "output material '{}' has a position without a location", + output.id + ); + } + continue; + }; + let location = Resource::Iri(Iri::new(location.clone()).with_context(|| { + format!( + "output material '{}' has an invalid location IRI", + output.id + ) + })?); + if let Some(zone) = document.zone(&location) { + if zone.facility_id() != Some(&selected_facility) { + bail!( + "output material '{}' is located in a Zone outside the selected facility", + output.id + ); + } + if output.position.is_some() { + bail!( + "output material '{}' cannot name a position when located directly in a Zone", + output.id + ); + } + continue; + } + let asset = document.asset(&location).with_context(|| { + format!( + "output material '{}' location '{}' is not a local Zone or Asset", + output.id, location + ) + })?; + if asset.facility_id() != Some(&selected_facility) { + bail!( + "output material '{}' is located in an Asset outside the selected facility", + output.id + ); + } + let allowed = asset.allowed_positions().collect::>(); + if !allowed.is_empty() + && output + .position + .as_deref() + .is_none_or(|position| !allowed.contains(position)) + { + bail!( + "output material '{}' needs one of Asset '{}' positions: {}", + output.id, + location, + allowed.into_iter().collect::>().join(", ") + ); + } + if let Some(position) = output.position.as_deref() { + if position.trim().is_empty() { + bail!("output material '{}' has a blank position", output.id); + } + let occupied_by_asset = document.assets().any(|candidate| { + candidate.located_in_id() == Some(&location) + && candidate.position() == Some(position) + }); + let occupied_by_material = document.material_lots().any(|candidate| { + candidate.located_in_id() == Some(&location) + && candidate.position() == Some(position) + }); + if occupied_by_asset || occupied_by_material { + bail!( + "output material '{}' targets occupied position '{}' on Asset '{}'", + output.id, + position, + location + ); + } + if !planned_occupancy.insert((location.clone(), position.to_owned())) { + bail!( + "several output materials target position '{}' on Asset '{}'", + position, + location + ); + } + } + } + Ok(()) +} + +fn scalar_equal(expected: &ExecutionParameterValue, observed: &FacilityScalarValue) -> bool { + match (expected, observed) { + (ExecutionParameterValue::Text(left), FacilityScalarValue::Text(right)) + | (ExecutionParameterValue::Integer(left), FacilityScalarValue::Integer(right)) + | (ExecutionParameterValue::Real(left), FacilityScalarValue::Real(right)) => left == right, + (ExecutionParameterValue::Boolean(left), FacilityScalarValue::Boolean(right)) => { + left == right + } + (ExecutionParameterValue::Iri(left), FacilityScalarValue::Iri(right)) => { + left == right.as_str() + } + _ => false, + } +} + +fn read_frozen_input( + directory: &Path, + relative: &str, + expected_sha256: &str, + label: &str, +) -> Result> { + let joined = directory.join(relative); + let resolved = fs::canonicalize(&joined) + .with_context(|| format!("failed to resolve {label} at {}", joined.display()))?; + if !resolved.starts_with(directory) { + bail!("{label} path '{relative}' resolves outside the execution directory"); + } + let bytes = fs::read(&resolved) + .with_context(|| format!("failed to read {label} at {}", resolved.display()))?; + let observed = sha256_hex(&bytes); + if observed != expected_sha256 { + bail!( + "{label} at '{}' has SHA-256 {observed}, but the reviewed plan requires {expected_sha256}", + relative + ); + } + Ok(bytes) +} + +fn load_reviewed_document( + driver: &str, + format: &str, + expected_capability_kind: &str, + bytes: &[u8], + path: &Path, +) -> Result { + match (driver, format) { + ("hamilton.star", STAR_RUN_FORMAT) => { + let document: StarRunDocument = parse_json_document(bytes, path)?; + if document.format != STAR_RUN_FORMAT { + bail!( + "{} declares format '{}', expected '{}'", + path.display(), + document.format, + STAR_RUN_FORMAT + ); + } + if !document.manual_after.is_empty() { + bail!( + "{} carries manual-after steps; facility execution requires explicit Manual plan nodes", + path.display() + ); + } + let commands = document + .steps + .iter() + .map(|step| { + RawCommand::parse(&step.frame).with_context(|| { + format!("{} carries an unreplayable STAR frame", path.display()) + }) + }) + .collect::>>()?; + Ok(LoadedReviewedDocument::Star { document, commands }) + } + ("inheco.odtc", THERMOCYCLE_RUN_FORMAT) => { + let document: ThermocycleRunDocument = parse_json_document(bytes, path)?; + if document.format != THERMOCYCLE_RUN_FORMAT { + bail!( + "{} declares format '{}', expected '{}'", + path.display(), + document.format, + THERMOCYCLE_RUN_FORMAT + ); + } + document + .profile + .validate(&lab_instruments::odtc_thermal_limits()) + .with_context(|| { + format!("{} is outside the Inheco ODTC envelope", path.display()) + })?; + Ok(LoadedReviewedDocument::Thermocycle(document)) + } + ("byonoy.absorbance96", PLATE_READ_FORMAT) => { + let document: PlateReadDocument = parse_json_document(bytes, path)?; + if document.format != PLATE_READ_FORMAT { + bail!( + "{} declares format '{}', expected '{}'", + path.display(), + document.format, + PLATE_READ_FORMAT + ); + } + Ok(LoadedReviewedDocument::PlateRead(document)) + } + ("lab.simulator", SIMULATION_RUN_FORMAT) => { + let document: SimulationRunDocument = parse_json_document(bytes, path)?; + if document.format != SIMULATION_RUN_FORMAT { + bail!( + "{} declares format '{}', expected '{}'", + path.display(), + document.format, + SIMULATION_RUN_FORMAT + ); + } + Iri::new(document.capability_kind.clone()).with_context(|| { + format!("{} declares an invalid capability-kind IRI", path.display()) + })?; + if document.capability_kind != expected_capability_kind { + bail!( + "{} simulates capability '{}', but its frozen requirement binds '{}'", + path.display(), + document.capability_kind, + expected_capability_kind + ); + } + Ok(LoadedReviewedDocument::Simulation(document)) + } + _ => bail!( + "adapter '{driver}' has no runtime executor for reviewed document format '{format}'" + ), + } +} + +fn parse_json_document(bytes: &[u8], path: &Path) -> Result { + serde_json::from_slice(bytes) + .with_context(|| format!("{} is not a valid reviewed run document", path.display())) +} + +fn topological_nodes(nodes: &[ExecutionPlanNode]) -> Vec<&ExecutionPlanNode> { + let by_id = nodes + .iter() + .map(|node| (node.id.as_str(), node)) + .collect::>(); + let mut indegree = nodes + .iter() + .map(|node| (node.id.as_str(), node.after.len())) + .collect::>(); + let mut dependents = BTreeMap::<&str, Vec<&str>>::new(); + for node in nodes { + for dependency in &node.after { + dependents + .entry(dependency.as_str()) + .or_default() + .push(node.id.as_str()); + } + } + let mut ready = indegree + .iter() + .filter_map(|(id, degree)| (*degree == 0).then_some(*id)) + .collect::>(); + let mut ordered = Vec::with_capacity(nodes.len()); + while let Some(id) = ready.pop_first() { + ordered.push(by_id[id]); + for dependent in dependents.get(id).into_iter().flatten() { + let degree = indegree + .get_mut(dependent) + .expect("execution-plan validation resolved every dependency"); + *degree -= 1; + if *degree == 0 { + ready.insert(dependent); + } + } + } + debug_assert_eq!(ordered.len(), nodes.len()); + ordered +} + +fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[cfg(test)] +pub(crate) mod tests { + use std::sync::{Arc, Mutex}; + + use lab_runfmt::{ + ExecutionAdapterBinding, ExecutionInventoryReference, ExecutionMaterialBinding, + ExecutionMaterialOutput, ExecutionPlanAction, ExecutionPlanNode, + ExecutionRequirementBinding, ReviewedRunDocument, RunStep, STAR_RUN_FORMAT, + StarRunDocument, + }; + + use super::*; + use crate::clock::Clock; + use crate::events::{RecordingSink, RunEvent}; + use crate::operator::AutoOperator; + + const INVENTORY: &str = r#"@prefix cap: . +@prefix ex: . +@prefix fac: . +@prefix inv: . +@prefix sbol: . + +ex:facility a sbol:TopLevel, fac:Facility ; sbol:displayId "facility" ; + sbol:hasNamespace . +ex:room a sbol:TopLevel, fac:Zone ; sbol:displayId "room" ; + sbol:hasNamespace ; fac:facility ex:facility ; + fac:zoneKind fac:Room ; fac:isActive true . +ex:star a sbol:TopLevel, fac:Asset ; sbol:displayId "star" ; + sbol:hasNamespace ; fac:facility ex:facility ; + fac:assetKind fac:Instrument ; fac:locatedIn ex:room ; fac:isActive true ; + fac:capability . + + a sbol:Identified, fac:CapabilityOffering ; sbol:displayId "liquid_handling" ; + fac:capabilityKind cap:LiquidHandling ; fac:qualification fac:Executable ; + fac:controlMode fac:ReviewedFileControl ; fac:isActive true . +ex:design a sbol:Component ; sbol:displayId "design" ; + sbol:hasNamespace ; + sbol:type . +ex:input_lot a sbol:Implementation ; sbol:displayId "input_lot" ; + sbol:hasNamespace ; sbol:built ex:design ; + fac:materialKind inv:DnaSample ; fac:facility ex:facility ; fac:isActive true ; + fac:locatedIn ex:room . +"#; + + struct FixedClock; + + impl Clock for FixedClock { + fn now_unix(&self) -> u64 { + 1_725_000_000 + } + } + + struct RecordingExecutor { + calls: Arc>>, + } + + impl DocumentExecutor for RecordingExecutor { + fn execute( + &mut self, + document: &LoadedReviewedDocument, + _events: &mut dyn EventSink, + ) -> Result<()> { + self.calls.lock().unwrap().push(document.title().to_owned()); + Ok(()) + } + } + + fn registry(calls: Arc>>) -> ExecutorRegistry { + let mut registry = ExecutorRegistry::new(); + registry + .register( + "https://example.org/facility/star", + "hamilton.star", + STAR_RUN_FORMAT, + Box::new(RecordingExecutor { calls }), + ) + .unwrap(); + registry + } + + pub(crate) fn write_execution_package() -> tempfile::TempDir { + let directory = tempfile::tempdir().unwrap(); + fs::create_dir_all(directory.path().join("adapters")).unwrap(); + fs::create_dir_all(directory.path().join("runs")).unwrap(); + fs::write(directory.path().join("inventory-source.ttl"), INVENTORY).unwrap(); + fs::write(directory.path().join("adapters/star.toml"), "").unwrap(); + + let run = StarRunDocument { + format: STAR_RUN_FORMAT.to_owned(), + run: "transfer".to_owned(), + title: "Transfer liquids".to_owned(), + machine: "STARlet".to_owned(), + channels: 8, + steps: vec![RunStep { + frame: "C0ZA".to_owned(), + module: "C0".to_owned(), + code: "ZA".to_owned(), + description: "Retract channels".to_owned(), + }], + manual_after: Vec::new(), + }; + let mut run_bytes = serde_json::to_vec_pretty(&run).unwrap(); + run_bytes.push(b'\n'); + fs::write(directory.path().join("runs/transfer.star.json"), &run_bytes).unwrap(); + + let plan = ExecutionPlanDocument { + format: EXECUTION_PLAN_FORMAT.to_owned(), + inventory: ExecutionInventoryReference { + document: "inventory-source.ttl".to_owned(), + source_sha256: sha256_hex(INVENTORY.as_bytes()), + facility: "https://example.org/facility/facility".to_owned(), + }, + requirements: vec![ExecutionRequirementBinding { + requirement_instance: "workflow/main/liquid".to_owned(), + requirement_template: "workflow::main::liquid".to_owned(), + capability_kind: "https://sbol.io/ns/capability#LiquidHandling".to_owned(), + offering: "https://example.org/facility/star/liquid_handling".to_owned(), + asset: "https://example.org/facility/star".to_owned(), + minimum_qualification: "https://sbol.io/ns/facility#Executable".to_owned(), + observed_qualification: "https://sbol.io/ns/facility#Executable".to_owned(), + control_mode: "https://sbol.io/ns/facility#ReviewedFileControl".to_owned(), + parameters: Vec::new(), + adapter: Some(ExecutionAdapterBinding { + driver: "hamilton.star".to_owned(), + profile_path: "adapters/star.toml".to_owned(), + profile_sha256: sha256_hex(b""), + }), + }], + materials: vec![ExecutionMaterialBinding { + id: "input".to_owned(), + component: "https://example.org/facility/design".to_owned(), + material_lot: "https://example.org/facility/input_lot".to_owned(), + }], + outputs: vec![ExecutionMaterialOutput { + id: "output".to_owned(), + material_lot: "https://example.org/results/output_lot".to_owned(), + namespace: "https://example.org/results".to_owned(), + display_id: "output_lot".to_owned(), + component: "https://example.org/facility/design".to_owned(), + material_kind: "https://sbol.io/ns/inventory#DnaSample".to_owned(), + located_in: Some("https://example.org/facility/room".to_owned()), + position: None, + derived_from: vec!["input".to_owned()], + }], + lowerings: Vec::new(), + // Serialized order is intentionally not dependency order. + nodes: vec![ + ExecutionPlanNode { + id: "execute-0001".to_owned(), + after: vec!["prepare".to_owned()], + action: ExecutionPlanAction::Execute { + requirement: "workflow/main/liquid".to_owned(), + document: Some(ReviewedRunDocument { + path: "runs/transfer.star.json".to_owned(), + format: STAR_RUN_FORMAT.to_owned(), + sha256: sha256_hex(&run_bytes), + }), + }, + }, + ExecutionPlanNode { + id: "prepare".to_owned(), + after: Vec::new(), + action: ExecutionPlanAction::Manual { + title: "Prepare the deck".to_owned(), + instructions: "Confirm the reviewed deck layout.".to_owned(), + }, + }, + ], + }; + let mut plan_bytes = serde_json::to_vec_pretty(&plan).unwrap(); + plan_bytes.push(b'\n'); + fs::write(directory.path().join(EXECUTION_PLAN_FILE), plan_bytes).unwrap(); + directory + } + + #[test] + fn preflight_validates_every_frozen_input_and_orders_the_dag() { + let directory = write_execution_package(); + let plan_bytes = fs::read(directory.path().join(EXECUTION_PLAN_FILE)).unwrap(); + + let loaded = load_execution_directory(directory.path()).unwrap(); + + assert_eq!(loaded.plan_sha256, sha256_hex(&plan_bytes)); + assert_eq!(loaded.nodes[0].id, "prepare"); + assert_eq!(loaded.nodes[1].id, "execute-0001"); + assert!(loaded.is_ready(ExecutionMode::Live)); + let LoadedExecutionAction::Execute { + document: Some(document), + .. + } = &loaded.nodes[1].action + else { + panic!("the execute node should hold its prevalidated document") + }; + assert_eq!(document.format(), STAR_RUN_FORMAT); + } + + #[test] + fn preflight_refuses_changed_inventory_profiles_and_documents() { + let inventory = write_execution_package(); + fs::write( + inventory.path().join("inventory-source.ttl"), + format!("{INVENTORY}\n# changed\n"), + ) + .unwrap(); + let error = load_execution_directory(inventory.path()) + .unwrap_err() + .to_string(); + assert!(error.contains("reviewed plan requires"), "{error}"); + + let profile = write_execution_package(); + fs::write( + profile.path().join("adapters/star.toml"), + "changed = true\n", + ) + .unwrap(); + let error = load_execution_directory(profile.path()) + .unwrap_err() + .to_string(); + assert!(error.contains("adapter profile"), "{error}"); + assert!(error.contains("reviewed plan requires"), "{error}"); + + let document = write_execution_package(); + fs::write(document.path().join("runs/transfer.star.json"), "{}\n").unwrap(); + let error = load_execution_directory(document.path()) + .unwrap_err() + .to_string(); + assert!(error.contains("reviewed run document"), "{error}"); + assert!(error.contains("reviewed plan requires"), "{error}"); + } + + #[test] + fn preflight_parses_document_contents_before_any_executor_can_open() { + let directory = write_execution_package(); + let document_path = directory.path().join("runs/transfer.star.json"); + let mut document: StarRunDocument = + serde_json::from_slice(&fs::read(&document_path).unwrap()).unwrap(); + document.steps[0].frame = "not a STAR frame".to_owned(); + let mut document_bytes = serde_json::to_vec_pretty(&document).unwrap(); + document_bytes.push(b'\n'); + fs::write(&document_path, &document_bytes).unwrap(); + + let plan_path = directory.path().join(EXECUTION_PLAN_FILE); + let mut plan: ExecutionPlanDocument = + serde_json::from_slice(&fs::read(&plan_path).unwrap()).unwrap(); + let ExecutionPlanAction::Execute { + document: Some(frozen), + .. + } = &mut plan.nodes[0].action + else { + panic!("the fixture should carry a reviewed document") + }; + frozen.sha256 = sha256_hex(&document_bytes); + let mut plan_bytes = serde_json::to_vec_pretty(&plan).unwrap(); + plan_bytes.push(b'\n'); + fs::write(&plan_path, plan_bytes).unwrap(); + + let error = load_execution_directory(directory.path()) + .unwrap_err() + .to_string(); + assert!(error.contains("unreplayable STAR frame"), "{error}"); + } + + #[test] + fn preflight_reprojects_exact_asset_and_offering_bindings() { + let directory = write_execution_package(); + let plan_path = directory.path().join(EXECUTION_PLAN_FILE); + let mut plan: ExecutionPlanDocument = + serde_json::from_slice(&fs::read(&plan_path).unwrap()).unwrap(); + plan.requirements[0].offering = "https://example.org/facility/star/missing".to_owned(); + let mut bytes = serde_json::to_vec_pretty(&plan).unwrap(); + bytes.push(b'\n'); + fs::write(&plan_path, bytes).unwrap(); + + let error = load_execution_directory(directory.path()) + .unwrap_err() + .to_string(); + assert!(error.contains("does not own"), "{error}"); + } + + #[test] + fn preflight_validates_planned_output_materials_before_execution() { + let directory = write_execution_package(); + let plan_path = directory.path().join(EXECUTION_PLAN_FILE); + let mut plan: ExecutionPlanDocument = + serde_json::from_slice(&fs::read(&plan_path).unwrap()).unwrap(); + plan.outputs[0].component = "https://example.org/facility/missing".to_owned(); + let mut bytes = serde_json::to_vec_pretty(&plan).unwrap(); + bytes.push(b'\n'); + fs::write(&plan_path, bytes).unwrap(); + + let error = load_execution_directory(directory.path()) + .unwrap_err() + .to_string(); + assert!(error.contains("references missing Component"), "{error}"); + assert!(!directory.path().join(LEDGER_FILE).exists()); + } + + #[test] + fn the_generic_runner_uses_only_the_exact_registered_executor_and_resumes() { + let directory = write_execution_package(); + let loaded = load_execution_directory(directory.path()).unwrap(); + let calls = Arc::new(Mutex::new(Vec::new())); + let mut registry = registry(Arc::clone(&calls)); + let mut events = RecordingSink::default(); + + let outcome = run_execution_plan( + &loaded, + ExecutionRunConfig { + assume_yes: true, + resume: false, + mode: ExecutionMode::Live, + }, + &mut registry, + &mut AutoOperator { answer: true }, + &mut events, + &FixedClock, + ) + .unwrap(); + + assert_eq!( + outcome, + ExecutionOutcome::Completed { + executed: 2, + skipped: 0, + started_at_unix_seconds: 1_725_000_000, + ended_at_unix_seconds: 1_725_000_000, + } + ); + assert_eq!(*calls.lock().unwrap(), ["Transfer liquids"]); + assert!(events.events.iter().any(|event| { + matches!( + event, + RunEvent::DocumentStarted { asset, driver, format, .. } + if asset == "https://example.org/facility/star" + && driver == "hamilton.star" + && format == STAR_RUN_FORMAT + ) + })); + + let resumed = run_execution_plan( + &loaded, + ExecutionRunConfig { + assume_yes: true, + resume: true, + mode: ExecutionMode::Live, + }, + &mut registry, + &mut AutoOperator { answer: true }, + &mut RecordingSink::default(), + &FixedClock, + ) + .unwrap(); + assert_eq!( + resumed, + ExecutionOutcome::Completed { + executed: 0, + skipped: 2, + started_at_unix_seconds: 1_725_000_000, + ended_at_unix_seconds: 1_725_000_000, + } + ); + assert_eq!( + calls.lock().unwrap().len(), + 1, + "resume never repeats a completed document" + ); + } + + #[test] + fn missing_or_inexact_executor_bindings_fail_before_a_ledger_exists() { + let directory = write_execution_package(); + let loaded = load_execution_directory(directory.path()).unwrap(); + let mut wrong_registry = ExecutorRegistry::new(); + wrong_registry + .register( + "https://example.org/facility/another-star", + "hamilton.star", + STAR_RUN_FORMAT, + Box::new(RecordingExecutor { + calls: Arc::new(Mutex::new(Vec::new())), + }), + ) + .unwrap(); + + let error = run_execution_plan( + &loaded, + ExecutionRunConfig { + assume_yes: true, + resume: false, + mode: ExecutionMode::Live, + }, + &mut wrong_registry, + &mut AutoOperator { answer: true }, + &mut RecordingSink::default(), + &FixedClock, + ) + .unwrap_err() + .to_string(); + + assert!(error.contains("no registered executor"), "{error}"); + assert!( + error.contains("https://example.org/facility/star"), + "{error}" + ); + assert!(!directory.path().join(LEDGER_FILE).exists()); + } + + #[test] + fn declining_the_pre_run_gate_leaves_no_execution_ledger() { + let directory = write_execution_package(); + let loaded = load_execution_directory(directory.path()).unwrap(); + let mut registry = registry(Arc::new(Mutex::new(Vec::new()))); + + let outcome = run_execution_plan( + &loaded, + ExecutionRunConfig { + assume_yes: false, + resume: false, + mode: ExecutionMode::Live, + }, + &mut registry, + &mut AutoOperator { answer: false }, + &mut RecordingSink::default(), + &FixedClock, + ) + .unwrap(); + + assert_eq!(outcome, ExecutionOutcome::Cancelled); + assert!(!directory.path().join(LEDGER_FILE).exists()); + } +} diff --git a/crates/lab-runtime/src/ledger.rs b/crates/lab-runtime/src/ledger.rs index 21f2c9a..8165df4 100644 --- a/crates/lab-runtime/src/ledger.rs +++ b/crates/lab-runtime/src/ledger.rs @@ -1,4 +1,4 @@ -//! The durable run ledger a workcell wave accumulates beside its plan. +//! The durable run ledger a reviewed facility execution accumulates beside its plan. //! //! The ledger is the run's memory and its evidence: which nodes completed, //! when, and on whose confirmation. @@ -6,24 +6,19 @@ use std::collections::BTreeSet; use std::fs; use std::io::Write as _; -use std::path::Path; +use std::path::{Path, PathBuf}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; use crate::clock::Clock; +use crate::mode::ExecutionMode; -/// The ledger file a wave accumulates beside its plan. +/// The ledger file a run accumulates beside its plan. pub const LEDGER_FILE: &str = "run-ledger.jsonl"; -/// One appended ledger record. -#[derive(Debug, Serialize, Deserialize)] -pub struct LedgerEntry { - pub node: String, - pub event: LedgerEvent, - /// Wall-clock seconds since the Unix epoch. - pub at_unix_seconds: u64, -} +/// The durable ledger format for generic facility-wide execution plans. +pub const EXECUTION_LEDGER_FORMAT: &str = "lab.execution-ledger.v2"; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -33,96 +28,398 @@ pub enum LedgerEvent { Failed, } -/// Appends one entry; every event is durable before the walk continues. -pub fn append_ledger( - directory: &Path, - node: &str, - event: LedgerEvent, - clock: &dyn Clock, -) -> Result<()> { - let entry = LedgerEntry { - node: node.to_string(), - event, - at_unix_seconds: clock.now_unix(), - }; - let mut line = serde_json::to_string(&entry)?; - line.push('\n'); - let path = directory.join(LEDGER_FILE); - let mut file = fs::OpenOptions::new() - .create(true) - .append(true) - .open(&path) - .with_context(|| format!("failed to open {}", path.display()))?; - file.write_all(line.as_bytes()) - .with_context(|| format!("failed to append to {}", path.display()))?; - Ok(()) +/// A generic execution ledger already bound to one exact reviewed plan. +#[derive(Debug)] +pub struct ExecutionLedger { + path: PathBuf, + plan_sha256: String, + valid_nodes: BTreeSet, + completed: BTreeSet, + started_at_unix_seconds: u64, + last_completed_at_unix_seconds: Option, } -/// The node ids the ledger records as completed. -pub fn completed_nodes(directory: &Path) -> Result> { - let path = directory.join(LEDGER_FILE); - if !path.is_file() { - return Ok(BTreeSet::new()); +impl ExecutionLedger { + /// Creates a fresh ledger. Existing physical state is never overwritten. + pub fn create( + directory: &Path, + plan_sha256: &str, + inventory_sha256: &str, + valid_nodes: BTreeSet, + mode: ExecutionMode, + clock: &dyn Clock, + ) -> Result { + let path = directory.join(LEDGER_FILE); + let started_at_unix_seconds = clock.now_unix(); + let header = ExecutionLedgerRecord::Header { + format: EXECUTION_LEDGER_FORMAT.to_owned(), + plan_sha256: plan_sha256.to_owned(), + inventory_sha256: inventory_sha256.to_owned(), + execution_mode: mode.as_str().to_owned(), + started_at_unix_seconds, + }; + let mut line = serde_json::to_string(&header)?; + line.push('\n'); + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .with_context(|| { + format!( + "{} already exists; resume the reviewed plan instead of replacing durable physical state", + path.display() + ) + })?; + file.write_all(line.as_bytes()) + .with_context(|| format!("failed to initialize {}", path.display()))?; + file.sync_data() + .with_context(|| format!("failed to sync {}", path.display()))?; + Ok(Self { + path, + plan_sha256: plan_sha256.to_owned(), + valid_nodes, + completed: BTreeSet::new(), + started_at_unix_seconds, + last_completed_at_unix_seconds: None, + }) } - let text = - fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; - let mut completed = BTreeSet::new(); - for (number, line) in text.lines().enumerate() { - if line.trim().is_empty() { - continue; - } - let entry: LedgerEntry = serde_json::from_str(line).with_context(|| { + + /// Opens a prior ledger only when it belongs to the exact preflighted plan and inventory. + pub fn resume( + directory: &Path, + plan_sha256: &str, + inventory_sha256: &str, + valid_nodes: BTreeSet, + mode: ExecutionMode, + ) -> Result { + let path = directory.join(LEDGER_FILE); + let text = fs::read_to_string(&path).with_context(|| { format!( - "{} line {} is not a ledger entry", - path.display(), - number + 1 + "cannot resume because {} does not exist; start without --resume", + path.display() ) })?; - if entry.event == LedgerEvent::Completed { - completed.insert(entry.node); + let mut records = Vec::new(); + for (number, line) in text.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + let record = + serde_json::from_str::(line).with_context(|| { + format!( + "{} line {} is not a {} record", + path.display(), + number + 1, + EXECUTION_LEDGER_FORMAT + ) + })?; + records.push(record); + } + let Some(ExecutionLedgerRecord::Header { + format, + plan_sha256: recorded_plan, + inventory_sha256: recorded_inventory, + execution_mode, + started_at_unix_seconds, + }) = records.first() + else { + bail!( + "{} has no {} header and cannot be resumed as a facility execution plan", + path.display(), + EXECUTION_LEDGER_FORMAT + ); + }; + if format != EXECUTION_LEDGER_FORMAT { + bail!( + "{} declares ledger format '{}', expected '{}'", + path.display(), + format, + EXECUTION_LEDGER_FORMAT + ); + } + if recorded_plan != plan_sha256 { + bail!( + "{} belongs to reviewed plan {}, but preflight loaded {}; substitutions require a new reviewed plan and a fresh run", + path.display(), + recorded_plan, + plan_sha256 + ); } + if recorded_inventory != inventory_sha256 { + bail!( + "{} belongs to inventory {}, but the reviewed plan names {}", + path.display(), + recorded_inventory, + inventory_sha256 + ); + } + if execution_mode != mode.as_str() { + bail!( + "{} belongs to execution mode '{}', but this run requested '{}'; simulation and live execution never share resume state", + path.display(), + execution_mode, + mode.as_str() + ); + } + let started_at_unix_seconds = *started_at_unix_seconds; + + let mut completed = BTreeSet::new(); + let mut last_completed_at_unix_seconds: Option = None; + for record in records.into_iter().skip(1) { + match record { + ExecutionLedgerRecord::Header { .. } => { + bail!("{} contains more than one ledger header", path.display()) + } + ExecutionLedgerRecord::Node { + plan_sha256: entry_plan, + node, + event, + at_unix_seconds, + } => { + if entry_plan != plan_sha256 { + bail!( + "{} contains a node record for reviewed plan {}, expected {}", + path.display(), + entry_plan, + plan_sha256 + ); + } + if !valid_nodes.contains(&node) { + bail!( + "{} records unknown node '{}'; it cannot resume this reviewed plan", + path.display(), + node + ); + } + if event == LedgerEvent::Completed { + completed.insert(node); + last_completed_at_unix_seconds = Some( + last_completed_at_unix_seconds + .map_or(at_unix_seconds, |prior| prior.max(at_unix_seconds)), + ); + } + } + } + } + Ok(Self { + path, + plan_sha256: plan_sha256.to_owned(), + valid_nodes, + completed, + started_at_unix_seconds, + last_completed_at_unix_seconds, + }) + } + + pub fn completed_nodes(&self) -> &BTreeSet { + &self.completed } - Ok(completed) + + pub fn started_at_unix_seconds(&self) -> u64 { + self.started_at_unix_seconds + } + + pub fn last_completed_at_unix_seconds(&self) -> Option { + self.last_completed_at_unix_seconds + } + + /// Appends and syncs one node transition before the runner proceeds. + pub fn append(&mut self, node: &str, event: LedgerEvent, clock: &dyn Clock) -> Result<()> { + if !self.valid_nodes.contains(node) { + bail!("cannot record unknown execution-plan node '{node}'"); + } + let at_unix_seconds = clock.now_unix(); + let record = ExecutionLedgerRecord::Node { + plan_sha256: self.plan_sha256.clone(), + node: node.to_owned(), + event, + at_unix_seconds, + }; + let mut line = serde_json::to_string(&record)?; + line.push('\n'); + let mut file = fs::OpenOptions::new() + .append(true) + .open(&self.path) + .with_context(|| format!("failed to open {}", self.path.display()))?; + file.write_all(line.as_bytes()) + .with_context(|| format!("failed to append to {}", self.path.display()))?; + file.sync_data() + .with_context(|| format!("failed to sync {}", self.path.display()))?; + if event == LedgerEvent::Completed { + self.completed.insert(node.to_owned()); + self.last_completed_at_unix_seconds = Some(at_unix_seconds); + } + Ok(()) + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "record", rename_all = "snake_case")] +enum ExecutionLedgerRecord { + Header { + format: String, + plan_sha256: String, + inventory_sha256: String, + execution_mode: String, + started_at_unix_seconds: u64, + }, + Node { + plan_sha256: String, + node: String, + event: LedgerEvent, + at_unix_seconds: u64, + }, } #[cfg(test)] mod tests { use super::*; - use crate::clock::WallClock; + + struct FixedClock; + + impl Clock for FixedClock { + fn now_unix(&self) -> u64 { + 1_725_000_000 + } + } + + fn nodes() -> BTreeSet { + ["prepare", "execute"] + .into_iter() + .map(str::to_owned) + .collect() + } #[test] - fn the_ledger_round_trips_and_reports_completed_nodes() { + fn an_execution_ledger_is_bound_to_the_exact_reviewed_plan() { let directory = tempfile::tempdir().unwrap(); - let clock = WallClock; - append_ledger( - directory.path(), - "assembly_run", - LedgerEvent::Started, - &clock, - ) - .unwrap(); - append_ledger( + let mut ledger = ExecutionLedger::create( directory.path(), - "assembly_run", - LedgerEvent::Completed, - &clock, + &"a".repeat(64), + &"b".repeat(64), + nodes(), + ExecutionMode::Live, + &FixedClock, ) .unwrap(); - append_ledger( + ledger + .append("prepare", LedgerEvent::Started, &FixedClock) + .unwrap(); + ledger + .append("prepare", LedgerEvent::Completed, &FixedClock) + .unwrap(); + ledger + .append("execute", LedgerEvent::Started, &FixedClock) + .unwrap(); + + let resumed = ExecutionLedger::resume( directory.path(), - "assembly_thermocycle", - LedgerEvent::Started, - &clock, + &"a".repeat(64), + &"b".repeat(64), + nodes(), + ExecutionMode::Live, ) .unwrap(); - let completed = completed_nodes(directory.path()).unwrap(); - assert!( - completed.contains("assembly_run"), - "a completed node is remembered" + + assert_eq!( + resumed.completed_nodes(), + &["prepare".to_owned()].into_iter().collect() ); + let header = fs::read_to_string(directory.path().join(LEDGER_FILE)).unwrap(); assert!( - !completed.contains("assembly_thermocycle"), - "a started-but-unfinished node is not skipped on resume" + header + .lines() + .next() + .unwrap() + .contains(EXECUTION_LEDGER_FORMAT) ); + assert!(header.lines().next().unwrap().contains(&"a".repeat(64))); + } + + #[test] + fn resume_refuses_a_changed_plan_or_inventory() { + let directory = tempfile::tempdir().unwrap(); + ExecutionLedger::create( + directory.path(), + &"a".repeat(64), + &"b".repeat(64), + nodes(), + ExecutionMode::Live, + &FixedClock, + ) + .unwrap(); + + let changed_plan = ExecutionLedger::resume( + directory.path(), + &"c".repeat(64), + &"b".repeat(64), + nodes(), + ExecutionMode::Live, + ) + .unwrap_err() + .to_string(); + assert!(changed_plan.contains("substitutions require a new reviewed plan")); + + let changed_inventory = ExecutionLedger::resume( + directory.path(), + &"a".repeat(64), + &"c".repeat(64), + nodes(), + ExecutionMode::Live, + ) + .unwrap_err() + .to_string(); + assert!(changed_inventory.contains("belongs to inventory")); + } + + #[test] + fn a_fresh_execution_never_overwrites_an_existing_ledger() { + let directory = tempfile::tempdir().unwrap(); + ExecutionLedger::create( + directory.path(), + &"a".repeat(64), + &"b".repeat(64), + nodes(), + ExecutionMode::Live, + &FixedClock, + ) + .unwrap(); + + let error = ExecutionLedger::create( + directory.path(), + &"a".repeat(64), + &"b".repeat(64), + nodes(), + ExecutionMode::Live, + &FixedClock, + ) + .unwrap_err() + .to_string(); + assert!(error.contains("resume the reviewed plan"), "{error}"); + } + + #[test] + fn simulation_resume_state_cannot_skip_live_execution() { + let directory = tempfile::tempdir().unwrap(); + ExecutionLedger::create( + directory.path(), + &"a".repeat(64), + &"b".repeat(64), + nodes(), + ExecutionMode::Simulation, + &FixedClock, + ) + .unwrap(); + + let error = ExecutionLedger::resume( + directory.path(), + &"a".repeat(64), + &"b".repeat(64), + nodes(), + ExecutionMode::Live, + ) + .unwrap_err() + .to_string(); + assert!(error.contains("never share resume state"), "{error}"); } } diff --git a/crates/lab-runtime/src/lib.rs b/crates/lab-runtime/src/lib.rs index b301258..a702b7e 100644 --- a/crates/lab-runtime/src/lib.rs +++ b/crates/lab-runtime/src/lib.rs @@ -4,23 +4,24 @@ //! everything in this crate interprets those documents without ever //! planning or deriving new work. Two execution modes share one node walk: //! -//! - **live execution** (`lab run`) drives real stations on a wall clock; -//! - **dry run** validates every document and narrates the walk without -//! touching hardware. +//! - **live execution** (`lab run`) drives exact bound Assets on a wall clock; +//! - **simulation** (`lab run --simulate`) uses no-hardware executors and mode-bound evidence. //! -//! The walk is parameterized over four ports: a [`clock::Clock`], an +//! A dry run validates every document and narrates the walk without opening a ledger or touching +//! hardware. +//! +//! Facility execution is parameterized over a [`clock::Clock`], an //! [`operator::Operator`] for confirmations, an [`events::EventSink`] for -//! narration, and a [`stations::Connector`] that opens station sessions. +//! narration, and exact Asset-bound document executors. pub mod clock; +pub mod device_executors; pub mod events; +pub mod execution; pub mod ledger; +pub mod mode; pub mod operator; +pub mod provenance; pub mod star; -pub mod stations; -pub mod workcell; - -#[cfg(test)] -pub(crate) mod testing; pub use hamilton_star; diff --git a/crates/lab-runtime/src/mode.rs b/crates/lab-runtime/src/mode.rs new file mode 100644 index 0000000..d2a70a9 --- /dev/null +++ b/crates/lab-runtime/src/mode.rs @@ -0,0 +1,16 @@ +//! Whether a reviewed plan is being simulated or executed against physical Assets. + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExecutionMode { + Simulation, + Live, +} + +impl ExecutionMode { + pub const fn as_str(self) -> &'static str { + match self { + Self::Simulation => "simulation", + Self::Live => "live", + } + } +} diff --git a/crates/lab-runtime/src/operator.rs b/crates/lab-runtime/src/operator.rs index 7391d9a..286ec04 100644 --- a/crates/lab-runtime/src/operator.rs +++ b/crates/lab-runtime/src/operator.rs @@ -11,7 +11,7 @@ use anyhow::Result; pub enum ConfirmKind { /// The gate before any motion starts. PreRun, - /// A labware movement between stations. + /// A material or labware movement between exact facility locations or Assets. Handoff, /// A by-hand step that is not a movement. Manual, diff --git a/crates/lab-runtime/src/provenance.rs b/crates/lab-runtime/src/provenance.rs new file mode 100644 index 0000000..4c38da3 --- /dev/null +++ b/crates/lab-runtime/src/provenance.rs @@ -0,0 +1,573 @@ +//! Post-run SBOLInventory result documents. +//! +//! A completed run produces a new graph. The reviewed source graph remains byte-for-byte +//! untouched; the result adds standard SBOL/PROV run records, exact Asset and MaterialLot +//! Usages, optional output MaterialLots and lineage, and hashed evidence Attachments. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::io::Write as _; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use lab_runfmt::{EXECUTION_PLAN_FILE, ExecutionMaterialOutput, ExecutionPlanAction}; +use sbol_inventory::InventoryDocument; +use sbol_inventory::vocabulary::{ + DERIVED_FROM_MATERIAL, FACILITY_PROPERTY, IS_ACTIVE, LOCATED_IN, MATERIAL_KIND, POSITION, + RUN_ASSET, RUN_INPUT_MATERIAL, XSD_BOOLEAN, XSD_STRING, +}; +use sbol3::{ + Activity, Agent, Association, Attachment, ExperimentalData, HashAlgorithm, Implementation, Iri, + Literal, Namespace, Plan, RdfFormat, RdfGraph, Resource, Term, ToRdf, Triple, Usage, +}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; + +use crate::execution::LoadedExecutionPlan; +use crate::ledger::LEDGER_FILE; +use crate::mode::ExecutionMode; + +pub const INVENTORY_RESULT_FILE: &str = "inventory-after.ttl"; +pub const SIMULATION_INVENTORY_RESULT_FILE: &str = "inventory-simulation.ttl"; + +pub const fn inventory_result_file(mode: ExecutionMode) -> &'static str { + match mode { + ExecutionMode::Simulation => SIMULATION_INVENTORY_RESULT_FILE, + ExecutionMode::Live => INVENTORY_RESULT_FILE, + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InventoryResult { + pub path: PathBuf, + pub activity: String, + pub evidence: String, + pub output_materials: Vec, +} + +/// Writes a new inventory result beside the reviewed plan after successful completion. +pub fn write_inventory_result( + loaded: &LoadedExecutionPlan, + mode: ExecutionMode, + started_at_unix_seconds: u64, + ended_at_unix_seconds: u64, +) -> Result { + if mode == ExecutionMode::Simulation && !loaded.plan.outputs.is_empty() { + bail!("simulation cannot generate physical output MaterialLots"); + } + let output_path = loaded.directory.join(inventory_result_file(mode)); + if output_path == loaded.inventory.source_path() { + bail!("refusing to overwrite the reviewed inventory source"); + } + if output_path.exists() { + bail!( + "{} already exists; preserving prior run provenance rather than overwriting it", + output_path.display() + ); + } + + let started_at = timestamp(started_at_unix_seconds)?; + let ended_at = timestamp(ended_at_unix_seconds)?; + if ended_at_unix_seconds < started_at_unix_seconds { + bail!("run completion time precedes its start time"); + } + let run_namespace = Namespace::new(format!( + "https://lab-lang.org/runs/{}/{}/{}", + mode.as_str(), + loaded.plan_sha256, + started_at_unix_seconds + ))?; + let run_resource = iri_resource(format!("{}/run", run_namespace.as_str()))?; + + let plan_bytes = read_evidence_file(&loaded.directory, EXECUTION_PLAN_FILE)?; + let ledger_bytes = read_evidence_file(&loaded.directory, LEDGER_FILE)?; + let mut evidence_files = vec![EvidenceFile { + display_id: "reviewed_plan_attachment".to_owned(), + name: EXECUTION_PLAN_FILE.to_owned(), + media_type: "https://www.iana.org/assignments/media-types/application/json".to_owned(), + bytes: plan_bytes, + }]; + evidence_files.push(EvidenceFile { + display_id: "run_ledger_attachment".to_owned(), + name: LEDGER_FILE.to_owned(), + media_type: "https://www.iana.org/assignments/media-types/application/jsonl".to_owned(), + bytes: ledger_bytes, + }); + + let mut child_paths = BTreeSet::new(); + for node in &loaded.plan.nodes { + if let ExecutionPlanAction::Execute { + document: Some(document), + .. + } = &node.action + && child_paths.insert(document.path.as_str()) + { + evidence_files.push(EvidenceFile { + display_id: format!("reviewed_document_{:04}", child_paths.len()), + name: document.path.clone(), + media_type: "https://www.iana.org/assignments/media-types/application/json" + .to_owned(), + bytes: read_evidence_file(&loaded.directory, &document.path)?, + }); + } + } + let mut profile_paths = BTreeSet::new(); + for requirement in &loaded.plan.requirements { + if let Some(adapter) = &requirement.adapter + && profile_paths.insert(adapter.profile_path.as_str()) + { + evidence_files.push(EvidenceFile { + display_id: format!("adapter_profile_{:04}", profile_paths.len()), + name: adapter.profile_path.clone(), + media_type: "https://www.iana.org/assignments/media-types/application/toml" + .to_owned(), + bytes: read_evidence_file(&loaded.directory, &adapter.profile_path)?, + }); + } + } + + let mut triples = loaded + .inventory + .document() + .as_sbol_document() + .rdf_graph() + .triples() + .iter() + .cloned() + .collect::>(); + let attachments = evidence_files + .iter() + .map(|file| build_attachment(&run_namespace, file)) + .collect::>>()?; + let plan_attachment = attachments + .first() + .expect("the reviewed plan is always attached") + .identity + .clone(); + for attachment in &attachments { + extend(&mut triples, attachment)?; + } + + let plan = Plan::builder(run_namespace.as_str(), "reviewed_plan")? + .name("Reviewed Lab execution plan") + .description(format!( + "Exact facility plan with SHA-256 {}", + loaded.plan_sha256 + )) + .add_attachment(plan_attachment) + .build()?; + let agent = Agent::builder(run_namespace.as_str(), "lab_runtime")? + .name(format!("Lab runtime {}", env!("CARGO_PKG_VERSION"))) + .build()?; + extend(&mut triples, &plan)?; + extend(&mut triples, &agent)?; + + let assets = loaded + .plan + .requirements + .iter() + .map(|binding| binding.asset.as_str()) + .collect::>(); + if assets.is_empty() { + bail!("a completed facility run must use at least one Asset"); + } + let mut usages = Vec::new(); + for (index, asset) in assets.iter().enumerate() { + usages.push( + Usage::builder(&run_resource, format!("asset_{:04}", index + 1))? + .entity(iri_resource(*asset)?) + .had_role([Iri::from_static(RUN_ASSET)]) + .build()?, + ); + } + for (index, material) in loaded.plan.materials.iter().enumerate() { + usages.push( + Usage::builder(&run_resource, format!("input_{:04}", index + 1))? + .entity(iri_resource(&material.material_lot)?) + .had_role([Iri::from_static(RUN_INPUT_MATERIAL)]) + .build()?, + ); + } + let association = Association::builder(&run_resource, "responsibility")? + .agent(agent.identity.clone()) + .had_plan(plan.identity.clone()) + .build()?; + let activity = Activity::builder(run_namespace.as_str(), "run")? + .name(match mode { + ExecutionMode::Simulation => "Lab facility simulation", + ExecutionMode::Live => "Lab facility run", + }) + .description(format!( + "{} of reviewed plan {} against facility {}", + match mode { + ExecutionMode::Simulation => "Simulation", + ExecutionMode::Live => "Execution", + }, + loaded.plan_sha256, + loaded.plan.inventory.facility + )) + .started_at_time(started_at) + .ended_at_time(ended_at) + .qualified_usage(usages.iter().map(|usage| usage.identity.clone())) + .add_qualified_association(association.identity.clone()) + .build()?; + for usage in &usages { + extend(&mut triples, usage)?; + } + extend(&mut triples, &association)?; + extend(&mut triples, &activity)?; + + let evidence = ExperimentalData::builder(run_namespace.as_str(), "evidence")? + .name(match mode { + ExecutionMode::Simulation => "Lab simulation evidence", + ExecutionMode::Live => "Lab run evidence", + }) + .description("Frozen reviewed inputs and the mode-bound durable execution ledger") + .attachments( + attachments + .iter() + .map(|attachment| attachment.identity.clone()), + ) + .add_generated_by(activity.identity.clone()) + .build()?; + extend(&mut triples, &evidence)?; + + let inputs = loaded + .plan + .materials + .iter() + .map(|material| { + ( + material.id.as_str(), + (material.material_lot.as_str(), material.component.as_str()), + ) + }) + .collect::>(); + let mut output_materials = Vec::new(); + for output in &loaded.plan.outputs { + let material = build_output_material( + output, + &loaded.plan.inventory.facility, + &activity.identity, + &inputs, + )?; + if loaded + .inventory + .document() + .as_sbol_document() + .get(&material.identity) + .is_some() + { + bail!( + "output MaterialLot '{}' already exists in the reviewed inventory", + output.material_lot + ); + } + output_materials.push(output.material_lot.clone()); + extend(&mut triples, &material)?; + } + + let document = InventoryDocument::from_sbol_document(sbol3::Document::from_rdf_graph( + RdfGraph::new(triples.into_iter().collect()), + )); + document + .check() + .map_err(anyhow::Error::new) + .context("post-run inventory document is not conformant")?; + let turtle = document.write(RdfFormat::Turtle)?; + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&output_path) + .with_context(|| format!("failed to create {}", output_path.display()))?; + file.write_all(turtle.as_bytes()) + .with_context(|| format!("failed to write {}", output_path.display()))?; + file.sync_data() + .with_context(|| format!("failed to sync {}", output_path.display()))?; + + Ok(InventoryResult { + path: output_path, + activity: activity.identity.to_string(), + evidence: evidence.identity.to_string(), + output_materials, + }) +} + +struct EvidenceFile { + display_id: String, + name: String, + media_type: String, + bytes: Vec, +} + +fn build_attachment(namespace: &Namespace, file: &EvidenceFile) -> Result { + let hash = sha256_hex(&file.bytes); + Ok( + Attachment::builder(namespace.as_str(), file.display_id.as_str())? + .name(file.name.clone()) + .source(iri_resource(format!("urn:sha256:{hash}"))?) + .format(Iri::new(file.media_type.clone())?) + .size(i64::try_from(file.bytes.len()).context("evidence file is too large")?) + .hash(hash) + .hash_algorithm(HashAlgorithm::SHA256) + .build()?, + ) +} + +fn build_output_material( + output: &ExecutionMaterialOutput, + facility: &str, + activity: &Resource, + inputs: &BTreeMap<&str, (&str, &str)>, +) -> Result { + let namespace = Namespace::new(output.namespace.clone())?; + let sources = output + .derived_from + .iter() + .map(|source| { + inputs + .get(source.as_str()) + .with_context(|| { + format!( + "output MaterialLot '{}' derives from unknown input '{}'", + output.id, source + ) + }) + .and_then(|(material_lot, _)| iri_resource(*material_lot)) + }) + .collect::>>()?; + let derived_components = output + .derived_from + .iter() + .map(|source| { + inputs + .get(source.as_str()) + .with_context(|| { + format!( + "output MaterialLot '{}' derives from unknown input '{}'", + output.id, source + ) + }) + .and_then(|(_, component)| iri_resource(*component)) + }) + .collect::>>()?; + let mut builder = Implementation::builder(namespace.as_str(), output.display_id.as_str())? + .name(output.id.clone()) + .built(iri_resource(&output.component)?) + .derived_from(derived_components) + .add_generated_by(activity.clone()) + .extension( + Iri::from_static(MATERIAL_KIND), + Term::Resource(iri_resource(&output.material_kind)?), + ) + .extension( + Iri::from_static(FACILITY_PROPERTY), + Term::Resource(iri_resource(facility)?), + ) + .extension( + Iri::from_static(IS_ACTIVE), + Term::Literal(Literal::new("true", Iri::from_static(XSD_BOOLEAN), None)), + ); + if let Some(location) = &output.located_in { + builder = builder.extension( + Iri::from_static(LOCATED_IN), + Term::Resource(iri_resource(location)?), + ); + } + if let Some(position) = &output.position { + builder = builder.extension( + Iri::from_static(POSITION), + Term::Literal(Literal::new( + position.clone(), + Iri::from_static(XSD_STRING), + None, + )), + ); + } + for source in sources { + builder = builder.extension( + Iri::from_static(DERIVED_FROM_MATERIAL), + Term::Resource(source), + ); + } + let material = builder.build()?; + if material.identity.to_string() != output.material_lot { + bail!( + "output MaterialLot '{}' does not match its namespace/display_id identity '{}'", + output.material_lot, + material.identity + ); + } + Ok(material) +} + +fn extend(value: &mut BTreeSet, object: &impl ToRdf) -> Result<()> { + value.extend(object.to_rdf_triples()?); + Ok(()) +} + +fn read_evidence_file(directory: &Path, relative: &str) -> Result> { + let path = directory.join(relative); + fs::read(&path).with_context(|| format!("failed to read evidence file {}", path.display())) +} + +fn iri_resource(value: impl Into) -> Result { + Ok(Resource::Iri(Iri::new(value.into())?)) +} + +fn timestamp(value: u64) -> Result { + let value = i64::try_from(value).context("run timestamp exceeds the supported range")?; + OffsetDateTime::from_unix_timestamp(value) + .context("run timestamp is outside the supported date range")? + .format(&Rfc3339) + .context("failed to format run timestamp") +} + +fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use sbol_inventory::InventoryDocument; + use sbol_inventory::vocabulary::{ + PROV_ACTIVITY, PROV_WAS_GENERATED_BY, RUN_ASSET, RUN_INPUT_MATERIAL, + }; + use sbol3::{RdfFormat, Resource}; + + use super::*; + use crate::clock::Clock; + use crate::execution::{load_execution_directory, tests::write_execution_package}; + use crate::ledger::{ExecutionLedger, LedgerEvent}; + use crate::mode::ExecutionMode; + + struct FixedClock; + + impl Clock for FixedClock { + fn now_unix(&self) -> u64 { + 1_725_000_000 + } + } + + #[test] + fn completion_writes_a_new_conformant_inventory_with_run_evidence_and_lineage() { + let directory = write_execution_package(); + let loaded = load_execution_directory(directory.path()).unwrap(); + let source_before = fs::read(loaded.inventory.source_path()).unwrap(); + let nodes = loaded + .nodes + .iter() + .map(|node| node.id.clone()) + .collect::>(); + let mut ledger = ExecutionLedger::create( + &loaded.directory, + &loaded.plan_sha256, + loaded.inventory.source_sha256(), + nodes, + ExecutionMode::Live, + &FixedClock, + ) + .unwrap(); + for node in &loaded.nodes { + ledger + .append(&node.id, LedgerEvent::Started, &FixedClock) + .unwrap(); + ledger + .append(&node.id, LedgerEvent::Completed, &FixedClock) + .unwrap(); + } + + let result = + write_inventory_result(&loaded, ExecutionMode::Live, 1_725_000_000, 1_725_000_000) + .unwrap(); + + assert_eq!( + fs::read(loaded.inventory.source_path()).unwrap(), + source_before + ); + assert_eq!(result.path, loaded.directory.join(INVENTORY_RESULT_FILE)); + assert_eq!( + result.output_materials, + ["https://example.org/results/output_lot"] + ); + let turtle = fs::read_to_string(&result.path).unwrap(); + let document = InventoryDocument::read(&turtle, RdfFormat::Turtle).unwrap(); + document.check().unwrap(); + + let output_identity = iri_resource("https://example.org/results/output_lot").unwrap(); + let output = document.material_lot(&output_identity).unwrap(); + assert_eq!( + output + .derived_from_ids() + .map(ToString::to_string) + .collect::>(), + ["https://example.org/facility/input_lot"] + ); + let implementation = output.as_implementation().unwrap(); + assert!( + implementation + .identified + .generated_by + .iter() + .any(|activity| activity.to_string() == result.activity) + ); + assert!( + implementation + .identified + .derived_from + .iter() + .any(|input| input.to_string() == "https://example.org/facility/design") + ); + + let graph = document.as_sbol_document(); + let activity = graph + .get(&Resource::Iri(Iri::new(result.activity.clone()).unwrap())) + .unwrap(); + assert!( + activity + .rdf_types() + .iter() + .any(|kind| kind.as_str() == PROV_ACTIVITY) + ); + let usage_roles = activity + .resources("http://www.w3.org/ns/prov#qualifiedUsage") + .filter_map(|usage| graph.get(usage)) + .flat_map(|usage| usage.iris("http://www.w3.org/ns/prov#hadRole")) + .map(Iri::as_str) + .collect::>(); + assert!(usage_roles.contains(RUN_ASSET)); + assert!(usage_roles.contains(RUN_INPUT_MATERIAL)); + assert!( + graph + .objects() + .values() + .filter(|object| { + object + .rdf_types() + .iter() + .any(|kind| kind.as_str() == "http://sbols.org/v3#Attachment") + }) + .count() + >= 4 + ); + let output_object = graph.get(&output_identity).unwrap(); + assert!( + output_object + .resources(PROV_WAS_GENERATED_BY) + .any(|activity| activity.to_string() == result.activity) + ); + + let overwrite = + write_inventory_result(&loaded, ExecutionMode::Live, 1_725_000_000, 1_725_000_000) + .unwrap_err() + .to_string(); + assert!(overwrite.contains("preserving prior run provenance")); + } +} diff --git a/crates/lab-runtime/src/star.rs b/crates/lab-runtime/src/star.rs index 94471b7..6c9765d 100644 --- a/crates/lab-runtime/src/star.rs +++ b/crates/lab-runtime/src/star.rs @@ -1,207 +1,32 @@ -//! Replay of an emitted Hamilton STAR run package. -//! -//! A `lab.star-run.v0` document is reviewed frames; this module loads a -//! package directory, validates every frame through the driver crate, and -//! replays them over an open session. Any firmware error retracts the -//! channels and reports the failed step. The dry-run rendering prints the -//! full step table and touches no hardware. +//! Hamilton STAR session construction for exact Asset-bound execution. -use std::fs; -use std::path::Path; -use std::sync::Arc; - -use anyhow::{Context, Result, bail}; -use hamilton_star::{RawCommand, Star, Transport}; -use lab_runfmt::ManualStep; -use serde::Deserialize; - -/// One `lab.star-run.v0` document, loaded and frame-validated. -pub struct LoadedRun { - pub id: String, - pub title: String, - pub steps: Vec, - pub manual_after: Vec, -} - -pub struct LoadedStep { - pub command: RawCommand, - pub description: String, -} - -/// The manifest fields the runner reads: run order and the bench's -/// initialize options. -#[derive(Deserialize)] -struct ManifestSummary { - target: String, - runs: Vec, - deck: ManifestDeck, -} - -#[derive(Deserialize)] -struct ManifestRun { - id: String, -} - -#[derive(Deserialize)] -struct ManifestDeck { - #[serde(default)] - run: ManifestRunOptions, -} - -#[derive(Deserialize, Default)] -struct ManifestRunOptions { - #[serde(default)] - autoload_park_track: Option, -} - -/// Loads a run directory: the automation manifest names the run order, and -/// every document's frames must parse before anything is reported ready. -pub fn load_run_directory(directory: &Path) -> Result<(Vec, Option)> { - let manifest_path = directory.join("automation_manifest.json"); - let manifest_text = fs::read_to_string(&manifest_path).with_context(|| { - format!( - "no automation manifest at {}; point lab run at a directory produced by `lab build` for a hamilton.star target", - manifest_path.display() - ) - })?; - let manifest: ManifestSummary = - serde_json::from_str(&manifest_text).context("failed to parse the automation manifest")?; - if manifest.target != "hamilton.star" { - bail!( - "this package was compiled for '{}'; lab run executes hamilton.star run documents only", - manifest.target - ); - } - - let mut runs = Vec::new(); - for run in &manifest.runs { - let path = directory.join(format!("{}.star.json", run.id)); - let document = lab_runfmt::load_star_run(&path)?; - let steps = document - .steps - .iter() - .map(|step| { - RawCommand::parse(&step.frame) - .map(|command| LoadedStep { - command, - description: step.description.clone(), - }) - .with_context(|| format!("{} carries an unreplayable frame", path.display())) - }) - .collect::>>()?; - runs.push(LoadedRun { - id: document.run, - title: document.title, - steps, - manual_after: document.manual_after, - }); - } - Ok((runs, manifest.deck.run.autoload_park_track)) -} - -/// The outcome of replaying one package. -#[derive(Debug, PartialEq, Eq)] -pub enum RunOutcome { - Completed { - steps: usize, - }, - /// A firmware error stopped the run; the channels were retracted and - /// physical state stands at the named step. - Aborted { - run_id: String, - step_index: usize, - error: String, - }, -} - -/// Replays loaded runs over an open session. `pause` is called between -/// runs with the manual-step text and must return `true` to continue — -/// the operator confirms the bench matches before more motion. -pub fn execute_runs( - star: &Star, - runs: &[LoadedRun], - pause: &mut dyn FnMut(&str) -> bool, - narrate: &mut dyn FnMut(&str), -) -> Result { - let mut executed = 0usize; - for (index, run) in runs.iter().enumerate() { - narrate(&format!( - "run {}: {} ({} steps)", - index + 1, - run.title, - run.steps.len() - )); - for (step_index, step) in run.steps.iter().enumerate() { - narrate(&format!(" [{:>3}] {}", step_index + 1, step.description)); - if let Err(error) = star.execute_raw(&step.command) { - // Any failure leaves the machine mid-motion: retract to - // Z-safety before handing control back. - let retract = RawCommand::parse("C0ZA") - .expect("the retract frame is a constant well-formed frame"); - let _ = star.execute_raw(&retract); - return Ok(RunOutcome::Aborted { - run_id: run.id.clone(), - step_index, - error: error.to_string(), - }); - } - executed += 1; - } - for manual in &run.manual_after { - let prompt = format!("{}: {}", manual.title, manual.instructions); - if !pause(&prompt) { - bail!("run stopped by the operator after '{}'", run.id); - } - } - } - Ok(RunOutcome::Completed { steps: executed }) -} - -/// Renders the dry-run step table: every frame, validated, with the manual -/// steps that follow each run. -pub fn render_dry_run(runs: &[LoadedRun]) -> String { - use std::fmt::Write; - let total_steps: usize = runs.iter().map(|run| run.steps.len()).sum(); - let mut human = format!( - "dry run: {} run document(s), {} frames, all validated\n", - runs.len(), - total_steps - ); - for run in runs { - let _ = write!(human, "\n{} — {}\n", run.id, run.title); - for (index, step) in run.steps.iter().enumerate() { - let _ = write!( - human, - " [{:>3}] {:<4} {}\n {}\n", - index + 1, - step.command.code(), - step.description, - step.command.frame(), - ); - } - for manual in &run.manual_after { - let _ = writeln!( - human, - " then by hand — {}: {}", - manual.title, manual.instructions - ); - } +#[cfg(feature = "hardware")] +use anyhow::Context; +#[cfg(any(feature = "hardware", test))] +use anyhow::Result; +#[cfg(any(feature = "hardware", test))] +use hamilton_star::{RawCommand, Star}; + +/// Executes one reviewed frame, retracting to Z-safety if the firmware rejects it. +#[cfg(any(feature = "hardware", test))] +pub(crate) fn execute_frame(star: &Star, command: &RawCommand) -> Result<()> { + if let Err(error) = star.execute_raw(command) { + let retract = + RawCommand::parse("C0ZA").expect("the retract frame is a constant well-formed frame"); + let _ = star.execute_raw(&retract); + return Err(error.into()); } - human -} - -/// Session construction over an arbitrary transport, so the replay loop is -/// exercised or simulated without hardware. -pub fn star_over(transport: Arc) -> Result { - Ok(Star::new(transport)?) + Ok(()) } -/// Opens the first Hamilton STAR on USB and runs the documented setup -/// choreography. +/// Opens the first Hamilton STAR on USB and runs the documented setup choreography. +/// +/// The caller may invoke this only after a reviewed facility plan has bound the +/// document to an exact Asset and passed complete preflight validation. #[cfg(feature = "hardware")] -pub fn open_usb_star(autoload_park_track: Option) -> Result { +pub(crate) fn open_usb_star(autoload_park_track: Option) -> Result { let star = Star::open_usb().context( - "no Hamilton STAR answered on USB; use --dry-run to review the package without hardware", + "no Hamilton STAR answered on USB; use --dry-run to review the facility plan without hardware", )?; star.initialize(hamilton_star::InitializeOptions { autoload_park_track, @@ -213,119 +38,52 @@ pub fn open_usb_star(autoload_park_track: Option) -> Result { #[cfg(test)] mod tests { - use super::*; - use hamilton_star::MockTransport; + use std::sync::Arc; - fn loaded(frames: &[(&str, &str)]) -> LoadedRun { - LoadedRun { - id: "test_run".into(), - title: "test".into(), - steps: frames - .iter() - .map(|(frame, description)| LoadedStep { - command: RawCommand::parse(frame).expect("test frames are well-formed"), - description: description.to_string(), - }) - .collect(), - manual_after: Vec::new(), - } - } + use hamilton_star::{MockTransport, Transport}; + + use super::*; #[test] - fn a_scripted_run_replays_every_frame_in_order() { + fn reviewed_frames_reach_the_star_in_order() { let transport = Arc::new(MockTransport::new()); transport.set_responder(|command| { let id = command.get(6..10).unwrap_or("0000").to_string(); vec![format!("{}id{id}er00/00", &command[..4])] }); - let star = star_over(transport.clone() as Arc).expect("mock opens"); - let runs = vec![loaded(&[ - ("C0TTtt00tf1tl0519tv03600tg2tu0", "define the small tip"), - ("C0ZA", "retract"), - ])]; - let outcome = execute_runs(&star, &runs, &mut |_| true, &mut |_| {}) - .expect("the scripted run completes"); - assert_eq!(outcome, RunOutcome::Completed { steps: 2 }); + let star = Star::new(transport.clone() as Arc).expect("mock opens"); + let define_tip = RawCommand::parse("C0TTtt00tf1tl0519tv03600tg2tu0").unwrap(); + let retract = RawCommand::parse("C0ZA").unwrap(); + + execute_frame(&star, &define_tip).unwrap(); + execute_frame(&star, &retract).unwrap(); + let written = transport.written(); - assert_eq!(written.len(), 2, "both frames reached the wire in order"); - assert!( - written[0].starts_with("C0TTid") && written[0].ends_with("tt00tf1tl0519tv03600tg2tu0"), - "the tip definition went first with the session's id spliced in: {}", - written[0] - ); + assert_eq!(written.len(), 2); + assert!(written[0].starts_with("C0TTid")); + assert!(written[1].starts_with("C0ZAid")); } #[test] - fn a_firmware_error_retracts_and_reports_the_failed_step() { + fn a_firmware_error_retracts_to_z_safety() { let transport = Arc::new(MockTransport::new()); transport.set_responder(|command| { let id = command.get(6..10).unwrap_or("0000").to_string(); if &command[2..4] == "TP" { - // The firmware refuses the pickup: a tip is already fitted. vec![format!("C0TPid{id}er07/00")] } else { vec![format!("{}id{id}er00/00", &command[..4])] } }); - let star = star_over(transport.clone() as Arc).expect("mock opens"); - let runs = vec![loaded(&[ - ("C0ZA", "retract"), - ( - "C0TPxp01179 01179 00000&yp2418 2328 0000&tm1 1 0&tt01tp2244tz2164th2450td0", - "pick up tips", - ), - ("C0ZA", "never reached"), - ])]; - let outcome = execute_runs(&star, &runs, &mut |_| true, &mut |_| {}) - .expect("an abort is an outcome, not a runner failure"); - let RunOutcome::Aborted { - run_id, - step_index, - error, - } = outcome - else { - panic!("the firmware error aborts the run"); - }; - assert_eq!(run_id, "test_run"); - assert_eq!(step_index, 1, "the pickup was the second step"); - assert!( - error.contains("already fitted"), - "the typed firmware meaning survives into the report: {error}" - ); - let written = transport.written(); - assert!( - written - .last() - .expect("frames were written") - .starts_with("C0ZAid"), - "the runner's last act is the Z-safety retract" - ); - } + let star = Star::new(transport.clone() as Arc).expect("mock opens"); + let pickup = RawCommand::parse( + "C0TPxp01179 01179 00000&yp2418 2328 0000&tm1 1 0&tt01tp2244tz2164th2450td0", + ) + .unwrap(); - #[test] - fn an_operator_decline_stops_between_runs() { - let transport = Arc::new(MockTransport::new()); - transport.set_responder(|command| { - let id = command.get(6..10).unwrap_or("0000").to_string(); - vec![format!("{}id{id}er00/00", &command[..4])] - }); - let star = star_over(transport.clone() as Arc).expect("mock opens"); - let mut first = loaded(&[("C0ZA", "retract")]); - first.manual_after.push(ManualStep { - title: "thermocycle".into(), - instructions: "off-deck".into(), - }); - let second = loaded(&[("C0ZA", "never reached")]); - let error = execute_runs(&star, &[first, second], &mut |_| false, &mut |_| {}) - .expect_err("declining the manual step stops the program"); - assert!( - error.to_string().contains("stopped by the operator"), - "the stop names its cause: {error}" - ); - assert_eq!( - transport.written().len(), - 1, - "nothing after the declined manual step reached the wire" - ); + let error = execute_frame(&star, &pickup).expect_err("the pickup is rejected"); + + assert!(error.to_string().contains("already fitted"), "{error}"); + assert!(transport.written().last().unwrap().starts_with("C0ZAid")); } } diff --git a/crates/lab-runtime/src/stations/mod.rs b/crates/lab-runtime/src/stations/mod.rs deleted file mode 100644 index 7cc23b2..0000000 --- a/crates/lab-runtime/src/stations/mod.rs +++ /dev/null @@ -1,239 +0,0 @@ -//! Station sessions: the executor's view of connected instruments. -//! -//! The walk talks to stations through two narrow session traits, one per -//! program shape: frame replay on a liquid handler, and thermal programs on -//! a cycler. Live sessions wrap the vendor drivers. Which one a walk gets is -//! the [`Connector`]'s decision, made once per station name. - -use std::collections::BTreeMap; - -#[cfg(feature = "hardware")] -use anyhow::Context; -use anyhow::{Result, bail}; -use hamilton_star::RawCommand; -use lab_instruments::{RunHandle, ThermalProfile}; - -use crate::events::EventSink; -#[cfg(feature = "hardware")] -use crate::events::RunEvent; -use crate::workcell::Bench; - -/// A session that replays reviewed STAR frames. -pub trait StarSession { - /// Executes one frame; the error is the firmware's meaning, as text. - fn execute(&mut self, command: &RawCommand) -> Result<(), String>; - /// Best-effort Z-safety retract after a failure; never fails louder - /// than the failure it follows. - fn retract(&mut self); -} - -/// A session that runs device-neutral thermal programs. -pub trait CyclerSession { - fn open_lid(&mut self) -> Result<()>; - fn close_lid(&mut self) -> Result<()>; - /// Drops any hold; the plate is out and nothing needs temperature. - fn stop(&mut self) -> Result<()>; - fn run_profile(&mut self, profile: &ThermalProfile) -> Result; - fn await_completion(&mut self, handle: RunHandle) -> Result<()>; - fn hold_block(&mut self, celsius: f64) -> Result<()>; - /// Warnings the device raised during the run, drained. - fn take_warnings(&mut self) -> Vec; -} - -/// One open station, whichever shape it has. -pub enum StationSession { - Star(Box), - Cycler(Box), -} - -/// Opens a session for a station the walk touches for the first time. The -/// production connector reaches hardware; tests can provide a local double. -pub trait Connector { - fn connect( - &mut self, - station: &str, - kind: &str, - bench: &Bench, - events: &mut dyn EventSink, - ) -> Result; -} - -/// The open sessions a walk accumulates: each station connects on first -/// use, keyed by name, and stays open for the wave. -pub struct Sessions<'connector> { - open: BTreeMap, - connector: &'connector mut dyn Connector, -} - -impl<'connector> Sessions<'connector> { - pub fn new(connector: &'connector mut dyn Connector) -> Self { - Self { - open: BTreeMap::new(), - connector, - } - } - - pub fn ensure( - &mut self, - station: &str, - kind: &str, - bench: &Bench, - events: &mut dyn EventSink, - ) -> Result<&mut StationSession> { - if !self.open.contains_key(station) { - let session = self.connector.connect(station, kind, bench, events)?; - self.open.insert(station.to_string(), session); - } - Ok(self - .open - .get_mut(station) - .expect("the session was just ensured")) - } - - pub fn ensure_star( - &mut self, - station: &str, - kind: &str, - bench: &Bench, - events: &mut dyn EventSink, - ) -> Result<&mut dyn StarSession> { - match self.ensure(station, kind, bench, events)? { - StationSession::Star(session) => Ok(session.as_mut()), - StationSession::Cycler(_) => { - bail!("station '{station}' is a cycler, not a liquid handler") - } - } - } - - pub fn ensure_cycler( - &mut self, - station: &str, - kind: &str, - bench: &Bench, - events: &mut dyn EventSink, - ) -> Result<&mut dyn CyclerSession> { - match self.ensure(station, kind, bench, events)? { - StationSession::Cycler(session) => Ok(session.as_mut()), - StationSession::Star(_) => { - bail!("station '{station}' is a liquid handler, not a cycler") - } - } - } -} - -/// A live STAR session over any transport. -pub struct LiveStar { - star: hamilton_star::Star, -} - -impl LiveStar { - pub fn new(star: hamilton_star::Star) -> Self { - Self { star } - } -} - -impl StarSession for LiveStar { - fn execute(&mut self, command: &RawCommand) -> Result<(), String> { - self.star - .execute_raw(command) - .map(|_| ()) - .map_err(|error| error.to_string()) - } - - fn retract(&mut self) { - let retract = - RawCommand::parse("C0ZA").expect("the retract frame is a constant well-formed frame"); - let _ = self.star.execute_raw(&retract); - } -} - -impl CyclerSession for lab_instruments::OdtcStation { - fn open_lid(&mut self) -> Result<()> { - lab_instruments::Thermocycler::open_lid(self).map_err(anyhow::Error::from) - } - - fn close_lid(&mut self) -> Result<()> { - lab_instruments::Thermocycler::close_lid(self).map_err(anyhow::Error::from) - } - - fn stop(&mut self) -> Result<()> { - lab_instruments::Thermocycler::stop(self).map_err(anyhow::Error::from) - } - - fn run_profile(&mut self, profile: &ThermalProfile) -> Result { - lab_instruments::Thermocycler::run_profile(self, profile).map_err(anyhow::Error::from) - } - - fn await_completion(&mut self, handle: RunHandle) -> Result<()> { - lab_instruments::Thermocycler::await_completion(self, handle).map_err(anyhow::Error::from) - } - - fn hold_block(&mut self, celsius: f64) -> Result<()> { - lab_instruments::Thermocycler::hold_block(self, celsius, None).map_err(anyhow::Error::from) - } - - fn take_warnings(&mut self) -> Vec { - lab_instruments::OdtcStation::take_warnings(self) - } -} - -/// The connector live runs use: USB for the STAR, the bench's address for -/// the ODTC. Available only with the `hardware` feature so consumers that -/// only inspect run documents never link libusb. -#[cfg(feature = "hardware")] -pub struct HardwareConnector; - -#[cfg(feature = "hardware")] -impl Connector for HardwareConnector { - fn connect( - &mut self, - station: &str, - kind: &str, - bench: &Bench, - events: &mut dyn EventSink, - ) -> Result { - match kind { - "hamilton.star" => { - events.emit(RunEvent::Connecting { - station: station.to_string(), - detail: "the first Hamilton STAR on USB".to_string(), - }); - let star = hamilton_star::Star::open_usb().context( - "no Hamilton STAR answered on USB; use --dry-run to review without hardware", - )?; - star.initialize(hamilton_star::InitializeOptions::default()) - .context( - "the setup choreography failed; the machine is not in a known state", - )?; - events.emit(RunEvent::Connected { - station: station.to_string(), - }); - Ok(StationSession::Star(Box::new(LiveStar::new(star)))) - } - "inheco.odtc" => { - let address = bench.addresses.get(station).with_context(|| { - format!( - "station '{station}' has no address on this bench; pass --station {station}= (the ODTC answers on port 8080)" - ) - })?; - let socket: std::net::SocketAddr = address.parse().with_context(|| { - format!("'{address}' is not an address for station '{station}'") - })?; - events.emit(RunEvent::Connecting { - station: station.to_string(), - detail: socket.to_string(), - }); - let session = lab_instruments::OdtcStation::connect(socket).with_context(|| { - format!("the {station} connection handshake failed at {socket}") - })?; - events.emit(RunEvent::Connected { - station: station.to_string(), - }); - Ok(StationSession::Cycler(Box::new(session))) - } - other => bail!( - "station '{station}' has kind '{other}', which this runner has no executor for" - ), - } - } -} diff --git a/crates/lab-runtime/src/testing.rs b/crates/lab-runtime/src/testing.rs deleted file mode 100644 index c27a6cb..0000000 --- a/crates/lab-runtime/src/testing.rs +++ /dev/null @@ -1,147 +0,0 @@ -//! Test fixtures: a synthetic workcell wave with the same shape the -//! workcell backend emits — one STAR run, a thermal program bracketed by -//! two handoffs, and a trailing manual step. - -use std::path::Path; - -use anyhow::{Result, bail}; -use hamilton_star::RawCommand; -use lab_instruments::{RunHandle, ThermalProfile}; - -use crate::events::EventSink; -use crate::stations::{Connector, CyclerSession, StarSession, StationSession}; -use crate::workcell::Bench; - -struct TestStar; - -impl StarSession for TestStar { - fn execute(&mut self, _command: &RawCommand) -> Result<(), String> { - Ok(()) - } - - fn retract(&mut self) {} -} - -#[derive(Default)] -struct TestCycler { - next_handle: u64, -} - -impl CyclerSession for TestCycler { - fn open_lid(&mut self) -> Result<()> { - Ok(()) - } - - fn close_lid(&mut self) -> Result<()> { - Ok(()) - } - - fn stop(&mut self) -> Result<()> { - Ok(()) - } - - fn run_profile(&mut self, _profile: &ThermalProfile) -> Result { - self.next_handle += 1; - Ok(RunHandle::new(self.next_handle)) - } - - fn await_completion(&mut self, _handle: RunHandle) -> Result<()> { - Ok(()) - } - - fn hold_block(&mut self, _celsius: f64) -> Result<()> { - Ok(()) - } - - fn take_warnings(&mut self) -> Vec { - Vec::new() - } -} - -/// A no-I/O connector used by workcell unit tests. -#[derive(Default)] -pub(crate) struct TestConnector; - -impl Connector for TestConnector { - fn connect( - &mut self, - station: &str, - kind: &str, - _bench: &Bench, - _events: &mut dyn EventSink, - ) -> Result { - match kind { - "hamilton.star" => Ok(StationSession::Star(Box::new(TestStar))), - "inheco.odtc" => Ok(StationSession::Cycler(Box::::default())), - other => bail!("test station '{station}' has unsupported kind '{other}'"), - } - } -} - -pub(crate) fn write_synthetic_wave(directory: &Path) { - let plan = serde_json::json!({ - "format": "lab.workcell-run.v0", - "stations": [ - { "name": "star-1", "kind": "hamilton.star", "program_dir": "stations/star-1" }, - { "name": "odtc-1", "kind": "inheco.odtc", "program_dir": "stations/odtc-1" } - ], - "nodes": [ - { "id": "assembly_run", "after": [], "action": "station-program", - "station": "star-1", "document": "stations/star-1/assembly_run.star.json" }, - { "id": "assembly_thermocycle.to-odtc-1", "after": ["assembly_run"], - "action": "handoff", "from": "star-1", "to": "odtc-1", - "labware": "reaction_plate", - "instructions": "Seal the reaction_plate and move it from star-1 to odtc-1; close the door." }, - { "id": "assembly_thermocycle", "after": ["assembly_thermocycle.to-odtc-1"], - "action": "station-program", "station": "odtc-1", - "document": "stations/odtc-1/assembly_thermocycle.odtc.json" }, - { "id": "assembly_thermocycle.return", "after": ["assembly_thermocycle"], - "action": "handoff", "from": "odtc-1", "to": "star-1", - "labware": "reaction_plate", - "instructions": "Retrieve the reaction_plate from odtc-1 and return it to the star-1 deck position it came from." }, - { "id": "assembly_run.manual-1", "after": ["assembly_thermocycle.return"], - "action": "manual", "title": "spread plates", - "instructions": "spread the transformation on selective agar" } - ] - }); - let star_run = serde_json::json!({ - "format": "lab.star-run.v0", - "run": "assembly_run", - "title": "Golden Gate assembly", - "machine": "STARlet", - "channels": 8, - "steps": [ - { "frame": "C0TTtt00tf1tl0519tv03600tg2tu0", "module": "C0", "code": "TT", - "description": "define the small tip" }, - { "frame": "C0ZA", "module": "C0", "code": "ZA", - "description": "retract all channels to Z-safety" } - ] - }); - let thermocycle = serde_json::json!({ - "format": "lab.thermocycle-run.v0", - "id": "assembly_thermocycle", - "title": "Golden Gate cycling", - "plate": "reaction_plate", - "profile": { "stages": [ - { "steps": [ { "celsius": 37.0, "hold_seconds": 90.0 } ], "repeats": 1 } - ] }, - "fill_volume_ul": 20.0 - }); - std::fs::create_dir_all(directory.join("stations/star-1")).unwrap(); - std::fs::create_dir_all(directory.join("stations/odtc-1")).unwrap(); - std::fs::write( - directory.join("plan.workcell.json"), - serde_json::to_string_pretty(&plan).unwrap(), - ) - .unwrap(); - std::fs::write( - directory.join("stations/star-1/assembly_run.star.json"), - serde_json::to_string_pretty(&star_run).unwrap(), - ) - .unwrap(); - std::fs::write( - directory.join("stations/odtc-1/assembly_thermocycle.odtc.json"), - serde_json::to_string_pretty(&thermocycle).unwrap(), - ) - .unwrap(); -} diff --git a/crates/lab-runtime/src/workcell.rs b/crates/lab-runtime/src/workcell.rs deleted file mode 100644 index 74452b0..0000000 --- a/crates/lab-runtime/src/workcell.rs +++ /dev/null @@ -1,765 +0,0 @@ -//! The workcell walk: one node-by-node interpretation of a coordination -//! plan for live execution and dry-run review. -//! -//! `plan.workcell.json` names every node; station programs run on their -//! instruments, and every handoff or manual step stops for the operator's -//! confirmation. During a live run a durable ledger records each node as it -//! completes, so an interrupted wave — a crash, a power cut, an overnight -//! incubation — resumes from the first incomplete node with `--resume` -//! instead of repeating motion that already happened. - -use std::collections::{BTreeMap, BTreeSet}; -use std::path::Path; - -use anyhow::{Context, Result, bail}; -use hamilton_star::RawCommand; -use lab_runfmt::{StarRunDocument, ThermocycleRunDocument, WORKCELL_PLAN_FILE, WorkcellAction}; - -use crate::clock::Clock; -use crate::events::{EventSink, ProgramExtent, RunEvent}; -use crate::ledger::{LEDGER_FILE, LedgerEvent, append_ledger, completed_nodes}; -use crate::operator::{ConfirmKind, Operator}; -use crate::stations::{Connector, Sessions}; - -/// A station program, loaded and validated up front so nothing is -/// discovered mid-walk. -pub enum LoadedProgram { - Star { - station: String, - document: StarRunDocument, - steps: Vec<(RawCommand, String)>, - }, - Thermocycle { - station: String, - document: ThermocycleRunDocument, - }, -} - -/// One executable unit of the walk, in plan order. -pub struct LoadedNode { - pub id: String, - /// Node ids that must complete first. The plan emits a linear chain - /// today; the loader still validates this ordering rather than trusting it. - pub after: Vec, - pub action: LoadedAction, -} - -pub enum LoadedAction { - Program(LoadedProgram), - Handoff { - from: String, - to: String, - labware: String, - instructions: String, - }, - Manual { - title: String, - instructions: String, - }, -} - -impl LoadedAction { - /// The operator-facing text for a handoff, naming the labware and both - /// endpoints. - pub fn handoff_prompt(from: &str, to: &str, labware: &str, instructions: &str) -> String { - format!("{instructions} ({labware}: {from} -> {to})") - } -} - -pub struct LoadedWorkcell { - pub nodes: Vec, - /// The thermocycler station's name, when the plan declares one. - pub thermocycler_station: Option, -} - -/// True when the directory holds a workcell coordination plan. -pub fn is_workcell_directory(directory: &Path) -> bool { - directory.join(WORKCELL_PLAN_FILE).is_file() -} - -/// Loads a wave directory: the coordination plan names every node, and -/// every referenced station document must parse and validate before -/// anything is reported ready. Thermal programs are checked against the -/// cycler's envelope here, so an unrunnable profile fails before any -/// motion — the same eagerness STAR frames get. -pub fn load_workcell_directory(directory: &Path) -> Result { - let plan = lab_runfmt::load_workcell_plan(directory)?; - - let station_kind = |name: &str| -> Result<&str> { - plan.stations - .iter() - .find(|station| station.name == name) - .map(|station| station.kind.as_str()) - .with_context(|| format!("the plan references station '{name}' it never declares")) - }; - - let mut nodes = Vec::new(); - for node in &plan.nodes { - let action = match &node.action { - WorkcellAction::StationProgram { station, document } => { - let path = directory.join(document); - match station_kind(station)? { - "hamilton.star" => { - let document = lab_runfmt::load_star_run(&path)?; - let steps = document - .steps - .iter() - .map(|step| { - RawCommand::parse(&step.frame) - .map(|command| (command, step.description.clone())) - .with_context(|| { - format!("{} carries an unreplayable frame", path.display()) - }) - }) - .collect::>>()?; - LoadedAction::Program(LoadedProgram::Star { - station: station.clone(), - document, - steps, - }) - } - "inheco.odtc" => { - let document = lab_runfmt::load_thermocycle(&path)?; - document - .profile - .validate(&lab_instruments::odtc_thermal_limits()) - .with_context(|| { - format!("'{}' is outside the {station} envelope", document.id) - })?; - LoadedAction::Program(LoadedProgram::Thermocycle { - station: station.clone(), - document, - }) - } - other => bail!( - "station '{station}' has kind '{other}', which this runner has no executor for" - ), - } - } - WorkcellAction::Handoff { - from, - to, - labware, - instructions, - } => LoadedAction::Handoff { - from: from.clone(), - to: to.clone(), - labware: labware.clone(), - instructions: instructions.clone(), - }, - WorkcellAction::Manual { - title, - instructions, - } => LoadedAction::Manual { - title: title.clone(), - instructions: instructions.clone(), - }, - }; - nodes.push(LoadedNode { - id: node.id.clone(), - after: node.after.clone(), - action, - }); - } - let thermocycler_station = plan - .stations - .iter() - .find(|station| station.kind == "inheco.odtc") - .map(|station| station.name.clone()); - Ok(LoadedWorkcell { - nodes, - thermocycler_station, - }) -} - -/// Renders the dry-run walk: every node in order, with program contents -/// summarized the way the live run narrates them. -pub fn render_dry_run(loaded: &LoadedWorkcell) -> String { - use std::fmt::Write; - let mut text = String::new(); - let _ = writeln!( - text, - "dry run: {} coordination node(s), all documents validated", - loaded.nodes.len() - ); - for (index, node) in loaded.nodes.iter().enumerate() { - match &node.action { - LoadedAction::Program(LoadedProgram::Star { - station, - document, - steps, - }) => { - let _ = writeln!( - text, - "\n[{}] {} on {station} — {} ({} frames)", - index + 1, - node.id, - document.title, - steps.len() - ); - } - LoadedAction::Program(LoadedProgram::Thermocycle { station, document }) => { - let _ = writeln!( - text, - "\n[{}] {} on {station} — {} ({} plateaus{})", - index + 1, - node.id, - document.title, - document.profile.total_steps(), - match document.final_hold_celsius { - Some(celsius) => format!(", then hold {celsius} °C"), - None => String::new(), - } - ); - } - LoadedAction::Handoff { - from, - to, - labware, - instructions, - } => { - let _ = writeln!( - text, - "\n[{}] {} — by hand: {}", - index + 1, - node.id, - LoadedAction::handoff_prompt(from, to, labware, instructions) - ); - } - LoadedAction::Manual { - title, - instructions, - } => { - let _ = writeln!( - text, - "\n[{}] {} — by hand: {title}: {instructions}", - index + 1, - node.id - ); - } - } - } - text -} - -/// Bench context the walk carries: which station is the cycler, and where -/// stations answer on this bench. Addresses are runtime input — compiled -/// artifacts never carry them. -pub struct Bench { - pub thermocycler_station: Option, - pub addresses: BTreeMap, -} - -/// Parses repeated `--station NAME=ADDRESS` flags. -pub fn parse_station_addresses(entries: &[String]) -> Result> { - let mut addresses = BTreeMap::new(); - for entry in entries { - let Some((name, address)) = entry.split_once('=') else { - bail!("--station takes NAME=ADDRESS, e.g. --station odtc-1=169.254.10.40:8080"); - }; - addresses.insert(name.to_string(), address.to_string()); - } - Ok(addresses) -} - -/// How one live walk was configured. -pub struct RunConfig { - /// Skip the pre-run gate. Handoff and manual confirmations always ask: - /// they attest that a physical step happened, and no flag can attest - /// that for the operator. - pub assume_yes: bool, - pub resume: bool, -} - -/// How a walk ended. -#[derive(Debug, PartialEq, Eq)] -pub enum WorkcellOutcome { - Completed { - executed: usize, - skipped: usize, - }, - /// The operator declined the pre-run gate; nothing moved. - Cancelled, - /// The operator declined a handoff or manual step mid-walk. - Declined { - node: String, - }, - /// A station failed; the ledger holds every completed node. - Failed { - node: String, - error: String, - }, -} - -/// How one node's execution ended. -pub(crate) enum NodeRun { - Done, - Declined, -} - -/// The live workcell walk: validate everything, then walk the plan in -/// order, recording each node in the ledger as it completes. -#[allow(clippy::too_many_arguments)] -pub fn run_workcell( - directory: &Path, - loaded: &LoadedWorkcell, - bench: &Bench, - config: &RunConfig, - connector: &mut dyn Connector, - operator: &mut dyn Operator, - events: &mut dyn EventSink, - clock: &dyn Clock, -) -> Result { - let completed = if config.resume { - completed_nodes(directory)? - } else { - let ledger = directory.join(LEDGER_FILE); - if ledger.is_file() { - bail!( - "{} already exists; a wave that stopped mid-run continues with --resume, and a fresh run of the same wave means physical state this runner cannot verify — remove the ledger only if the bench was truly reset", - ledger.display() - ); - } - BTreeSet::new() - }; - - let pending = loaded - .nodes - .iter() - .filter(|node| !completed.contains(&node.id)) - .count(); - events.emit(RunEvent::Planned { - pending, - completed: completed.len(), - }); - if !config.assume_yes - && !operator.confirm(ConfirmKind::PreRun, "proceed? Stations will move. [y/N] ")? - { - return Ok(WorkcellOutcome::Cancelled); - } - - let mut sessions = Sessions::new(connector); - let mut executed = 0usize; - for node in &loaded.nodes { - if completed.contains(&node.id) { - events.emit(RunEvent::NodeSkipped { - id: node.id.clone(), - }); - continue; - } - append_ledger(directory, &node.id, LedgerEvent::Started, clock)?; - events.emit(RunEvent::NodeStarted { - id: node.id.clone(), - }); - match execute_node(node, &mut sessions, bench, operator, events) { - Ok(NodeRun::Done) => { - append_ledger(directory, &node.id, LedgerEvent::Completed, clock)?; - events.emit(RunEvent::NodeCompleted { - id: node.id.clone(), - }); - executed += 1; - } - Ok(NodeRun::Declined) => { - append_ledger(directory, &node.id, LedgerEvent::Failed, clock)?; - return Ok(WorkcellOutcome::Declined { - node: node.id.clone(), - }); - } - Err(error) => { - append_ledger(directory, &node.id, LedgerEvent::Failed, clock)?; - return Ok(WorkcellOutcome::Failed { - node: node.id.clone(), - error: format!("{error:#}"), - }); - } - } - } - Ok(WorkcellOutcome::Completed { - executed, - skipped: completed.len(), - }) -} - -/// True when a handoff endpoint is the cycler, whose motorized door the -/// runner must open before the operator can reach the block. -fn involves_cycler(bench: &Bench, station: &str) -> bool { - bench - .thermocycler_station - .as_deref() - .is_some_and(|cycler| cycler == station) -} - -/// Executes one node against its station, confirming with the operator -/// wherever the plan needs hands. -pub(crate) fn execute_node( - node: &LoadedNode, - sessions: &mut Sessions, - bench: &Bench, - operator: &mut dyn Operator, - events: &mut dyn EventSink, -) -> Result { - match &node.action { - LoadedAction::Handoff { - from, - to, - labware, - instructions, - } => { - let to_cycler = involves_cycler(bench, to); - let from_cycler = involves_cycler(bench, from); - let cycler_endpoint = if to_cycler { - Some(to.as_str()) - } else if from_cycler { - Some(from.as_str()) - } else { - None - }; - if let Some(station) = cycler_endpoint { - let cycler = sessions.ensure_cycler(station, "inheco.odtc", bench, events)?; - cycler - .open_lid() - .with_context(|| format!("could not open the {station} door"))?; - events.emit(RunEvent::DoorOpened { - station: station.to_string(), - }); - } - let prompt = LoadedAction::handoff_prompt(from, to, labware, instructions); - events.emit(RunEvent::AttentionRequired { - node: node.id.clone(), - prompt, - }); - let confirmed = operator.confirm( - ConfirmKind::Handoff, - "done, and the bench matches the plan? Continue [y/N] ", - )?; - events.emit(RunEvent::AttentionReleased { - node: node.id.clone(), - }); - if !confirmed { - return Ok(NodeRun::Declined); - } - events.emit(RunEvent::LabwareMoved { - labware: labware.clone(), - from: from.clone(), - to: to.clone(), - }); - if let Some(station) = cycler_endpoint { - let cycler = sessions.ensure_cycler(station, "inheco.odtc", bench, events)?; - if from_cycler { - // The plate is out; nothing holds temperature for it now. - cycler - .stop() - .with_context(|| format!("could not stop {station} after retrieval"))?; - } - cycler - .close_lid() - .with_context(|| format!("could not close the {station} door"))?; - events.emit(RunEvent::DoorClosed { - station: station.to_string(), - }); - } - Ok(NodeRun::Done) - } - LoadedAction::Manual { - title, - instructions, - } => { - events.emit(RunEvent::AttentionRequired { - node: node.id.clone(), - prompt: format!("{title}: {instructions}"), - }); - let confirmed = operator.confirm( - ConfirmKind::Manual, - "done, and the bench matches the plan? Continue [y/N] ", - )?; - events.emit(RunEvent::AttentionReleased { - node: node.id.clone(), - }); - if confirmed { - Ok(NodeRun::Done) - } else { - Ok(NodeRun::Declined) - } - } - LoadedAction::Program(LoadedProgram::Star { - station, - document, - steps, - }) => { - events.emit(RunEvent::ProgramStarted { - station: station.clone(), - title: document.title.clone(), - extent: ProgramExtent::Frames { - frames: steps.len(), - }, - }); - let star = sessions.ensure_star(station, "hamilton.star", bench, events)?; - for (index, (command, description)) in steps.iter().enumerate() { - events.emit(RunEvent::Frame { - station: station.clone(), - index: index + 1, - description: description.clone(), - }); - if let Err(error) = star.execute(command) { - star.retract(); - bail!( - "firmware error at frame {}: {error}; channels were retracted to Z-safety", - index + 1 - ); - } - } - Ok(NodeRun::Done) - } - LoadedAction::Program(LoadedProgram::Thermocycle { station, document }) => { - events.emit(RunEvent::ProgramStarted { - station: station.clone(), - title: document.title.clone(), - extent: ProgramExtent::Plateaus { - plateaus: document.profile.total_steps(), - final_hold_celsius: document.final_hold_celsius, - }, - }); - let cycler = sessions.ensure_cycler(station, "inheco.odtc", bench, events)?; - let handle = cycler - .run_profile(&document.profile) - .with_context(|| format!("could not start '{}' on {station}", document.id))?; - events.emit(RunEvent::ThermalRunning { - station: station.clone(), - }); - cycler - .await_completion(handle) - .with_context(|| format!("'{}' did not complete on {station}", document.id))?; - for warning in cycler.take_warnings() { - events.emit(RunEvent::ThermalWarning { - station: station.clone(), - warning, - }); - } - if let Some(celsius) = document.final_hold_celsius { - cycler - .hold_block(celsius) - .with_context(|| format!("could not hold {celsius} °C on {station}"))?; - events.emit(RunEvent::ThermalHold { - station: station.clone(), - celsius, - }); - } - Ok(NodeRun::Done) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::clock::WallClock; - use crate::events::{RecordingSink, RunEvent}; - use crate::operator::AutoOperator; - use crate::testing::{TestConnector, write_synthetic_wave}; - - fn bench_for(loaded: &LoadedWorkcell) -> Bench { - Bench { - thermocycler_station: loaded.thermocycler_station.clone(), - addresses: std::collections::BTreeMap::new(), - } - } - - #[test] - fn a_wave_walks_in_order_and_records_every_node_in_the_ledger() { - let directory = tempfile::tempdir().unwrap(); - write_synthetic_wave(directory.path()); - let loaded = load_workcell_directory(directory.path()).unwrap(); - let bench = bench_for(&loaded); - let mut connector = TestConnector; - let mut operator = AutoOperator { answer: true }; - let mut sink = RecordingSink::default(); - let outcome = run_workcell( - directory.path(), - &loaded, - &bench, - &RunConfig { - assume_yes: true, - resume: false, - }, - &mut connector, - &mut operator, - &mut sink, - &WallClock, - ) - .unwrap(); - assert_eq!( - outcome, - WorkcellOutcome::Completed { - executed: 5, - skipped: 0 - } - ); - let started: Vec<&str> = sink - .events - .iter() - .filter_map(|event| match event { - RunEvent::NodeStarted { id } => Some(id.as_str()), - _ => None, - }) - .collect(); - assert_eq!( - started, - [ - "assembly_run", - "assembly_thermocycle.to-odtc-1", - "assembly_thermocycle", - "assembly_thermocycle.return", - "assembly_run.manual-1", - ], - "the walk follows plan order" - ); - let completed = crate::ledger::completed_nodes(directory.path()).unwrap(); - assert_eq!(completed.len(), 5, "every node is durable in the ledger"); - assert!( - sink.events.iter().any(|event| matches!( - event, - RunEvent::LabwareMoved { labware, .. } if labware == "reaction_plate" - )), - "a confirmed handoff records the labware movement" - ); - let doors: Vec<&RunEvent> = sink - .events - .iter() - .filter(|event| { - matches!( - event, - RunEvent::DoorOpened { .. } | RunEvent::DoorClosed { .. } - ) - }) - .collect(); - assert_eq!( - doors.len(), - 4, - "the cycler door opens and closes around each handoff" - ); - } - - #[test] - fn a_declining_operator_stops_the_walk_at_the_handoff() { - let directory = tempfile::tempdir().unwrap(); - write_synthetic_wave(directory.path()); - let loaded = load_workcell_directory(directory.path()).unwrap(); - let bench = bench_for(&loaded); - let mut connector = TestConnector; - // Confirms the pre-run gate implicitly (assume_yes), then declines - // the first handoff. - let mut operator = AutoOperator { answer: false }; - let mut sink = RecordingSink::default(); - let outcome = run_workcell( - directory.path(), - &loaded, - &bench, - &RunConfig { - assume_yes: true, - resume: false, - }, - &mut connector, - &mut operator, - &mut sink, - &WallClock, - ) - .unwrap(); - assert_eq!( - outcome, - WorkcellOutcome::Declined { - node: "assembly_thermocycle.to-odtc-1".to_string() - }, - "the walk stops at the first confirmation" - ); - let completed = crate::ledger::completed_nodes(directory.path()).unwrap(); - assert_eq!( - completed.len(), - 1, - "only the STAR run completed before the decline" - ); - } - - #[test] - fn resume_skips_ledgered_nodes_and_a_fresh_run_refuses_a_ledger() { - let directory = tempfile::tempdir().unwrap(); - write_synthetic_wave(directory.path()); - let loaded = load_workcell_directory(directory.path()).unwrap(); - let bench = bench_for(&loaded); - crate::ledger::append_ledger( - directory.path(), - "assembly_run", - crate::ledger::LedgerEvent::Completed, - &WallClock, - ) - .unwrap(); - - let mut connector = TestConnector; - let mut operator = AutoOperator { answer: true }; - let mut sink = RecordingSink::default(); - let fresh = run_workcell( - directory.path(), - &loaded, - &bench, - &RunConfig { - assume_yes: true, - resume: false, - }, - &mut connector, - &mut operator, - &mut sink, - &WallClock, - ); - assert!( - fresh.is_err(), - "a pre-existing ledger without --resume is physical state the runner cannot verify" - ); - - let resumed = run_workcell( - directory.path(), - &loaded, - &bench, - &RunConfig { - assume_yes: true, - resume: true, - }, - &mut connector, - &mut operator, - &mut sink, - &WallClock, - ) - .unwrap(); - assert_eq!( - resumed, - WorkcellOutcome::Completed { - executed: 4, - skipped: 1 - } - ); - assert!( - sink.events - .iter() - .any(|event| matches!(event, RunEvent::NodeSkipped { id } if id == "assembly_run")), - "the ledgered node is skipped, not re-run" - ); - } - - #[test] - fn loading_validates_thermal_documents_before_any_motion() { - let directory = tempfile::tempdir().unwrap(); - write_synthetic_wave(directory.path()); - let path = directory - .path() - .join("stations/odtc-1/assembly_thermocycle.odtc.json"); - let text = std::fs::read_to_string(&path).unwrap(); - // 240 °C is far outside any block envelope. - std::fs::write(&path, text.replace("37.0", "240.0")).unwrap(); - let error = match load_workcell_directory(directory.path()) { - Ok(_) => panic!("an unrunnable profile fails at load time"), - Err(error) => error, - }; - assert!( - format!("{error:#}").contains("envelope"), - "the error names the envelope: {error:#}" - ); - } -} diff --git a/crates/lab-runtime/tests/ebef_acceptance.rs b/crates/lab-runtime/tests/ebef_acceptance.rs new file mode 100644 index 0000000..ce4bf17 --- /dev/null +++ b/crates/lab-runtime/tests/ebef_acceptance.rs @@ -0,0 +1,399 @@ +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use lab_runfmt::{ + EXECUTION_PLAN_FILE, EXECUTION_PLAN_FORMAT, ExecutionAdapterBinding, + ExecutionInventoryReference, ExecutionMaterialBinding, ExecutionPlanAction, + ExecutionPlanDocument, ExecutionPlanNode, ExecutionRequirementBinding, ReviewedRunDocument, + SIMULATION_RUN_FORMAT, SimulationRunDocument, +}; +use lab_runtime::clock::Clock; +use lab_runtime::device_executors::ReviewedDocumentSimulationExecutor; +use lab_runtime::events::{RecordingSink, RunEvent}; +use lab_runtime::execution::{ + ExecutionOutcome, ExecutionRunConfig, ExecutorRegistry, load_execution_directory, + render_execution_dry_run, run_execution_plan, +}; +use lab_runtime::mode::ExecutionMode; +use lab_runtime::operator::AutoOperator; +use lab_runtime::provenance::{SIMULATION_INVENTORY_RESULT_FILE, write_inventory_result}; +use sbol_inventory::InventoryDocument; +use sbol_inventory::vocabulary::{ + ABSORBANCE_MEASUREMENT, ControlMode, INCUBATION, LIQUID_HANDLING, PROV_ENTITY, + PROV_QUALIFIED_USAGE, Qualification, +}; +use sbol3::{Iri, RdfFormat, Resource}; +use sha2::{Digest, Sha256}; + +const FACILITY: &str = "https://example.org/ebef/facility"; +const PHYSICAL_MICROLAB: &str = "https://example.org/ebef/microlab_prep"; +const PHYSICAL_EPOCH: &str = "https://example.org/ebef/biotek_epoch_2"; +const SIMULATED_MICROLAB: &str = "https://example.org/ebef-acceptance/microlab_prep_simulator"; +const SIMULATED_EPOCH: &str = "https://example.org/ebef-acceptance/epoch_2_simulator"; +const ASSAY_COMPONENT: &str = "https://example.org/ebef-acceptance/assay_plate_design"; +const ASSAY_LOT: &str = "https://example.org/ebef-acceptance/assay_plate_lot"; +const SIMULATOR: &str = "lab.simulator"; + +struct FixedClock; + +impl Clock for FixedClock { + fn now_unix(&self) -> u64 { + 1_725_000_000 + } +} + +#[test] +fn ebef_derived_facility_composes_three_capabilities_without_claiming_hardware_control() { + let directory = materialize_reviewed_simulation(); + let source_path = directory.path().join("inventory-source.ttl"); + let source_before = fs::read(&source_path).unwrap(); + let loaded = load_execution_directory(directory.path()).unwrap(); + + for asset in [PHYSICAL_MICROLAB, PHYSICAL_EPOCH] { + let physical = loaded.inventory.facility_asset(asset).unwrap(); + assert!( + physical + .offerings + .iter() + .all(|offering| offering.qualification == Qualification::Described) + ); + assert!( + physical + .offerings + .iter() + .all(|offering| offering.control_mode == ControlMode::Unspecified) + ); + } + assert!(loaded.is_ready(ExecutionMode::Simulation)); + assert!(!loaded.is_ready(ExecutionMode::Live)); + + let narration = render_execution_dry_run(&loaded); + for expected in [ + LIQUID_HANDLING, + INCUBATION, + ABSORBANCE_MEASUREMENT, + SIMULATED_MICROLAB, + SIMULATED_EPOCH, + "move assay_plate", + ] { + assert!( + narration.contains(expected), + "missing {expected}:\n{narration}" + ); + } + + let mut registry = simulation_registry(); + let mut events = RecordingSink::default(); + let outcome = run_execution_plan( + &loaded, + ExecutionRunConfig { + assume_yes: true, + resume: false, + mode: ExecutionMode::Simulation, + }, + &mut registry, + &mut AutoOperator { answer: true }, + &mut events, + &FixedClock, + ) + .unwrap(); + assert_eq!( + outcome, + ExecutionOutcome::Completed { + executed: 5, + skipped: 0, + started_at_unix_seconds: 1_725_000_000, + ended_at_unix_seconds: 1_725_000_000, + } + ); + assert_eq!( + events + .events + .iter() + .filter(|event| matches!(event, RunEvent::DocumentStarted { .. })) + .count(), + 3 + ); + assert_eq!( + events + .events + .iter() + .filter(|event| matches!(event, RunEvent::LabwareMoved { .. })) + .count(), + 2 + ); + + let result = write_inventory_result( + &loaded, + ExecutionMode::Simulation, + 1_725_000_000, + 1_725_000_000, + ) + .unwrap(); + assert_eq!( + result.path, + loaded.directory.join(SIMULATION_INVENTORY_RESULT_FILE) + ); + assert!(result.output_materials.is_empty()); + assert_eq!(fs::read(&source_path).unwrap(), source_before); + + let result_text = fs::read_to_string(&result.path).unwrap(); + let result_document = InventoryDocument::read(&result_text, RdfFormat::Turtle).unwrap(); + result_document.check().unwrap(); + let graph = result_document.as_sbol_document(); + let activity = graph + .get(&Resource::Iri(Iri::new(result.activity).unwrap())) + .unwrap(); + let used = activity + .resources(PROV_QUALIFIED_USAGE) + .filter_map(|usage| graph.get(usage)) + .flat_map(|usage| usage.resources(PROV_ENTITY)) + .map(|entity| entity.to_string()) + .collect::>(); + assert_eq!( + used, + BTreeSet::from([ + ASSAY_LOT.to_owned(), + SIMULATED_EPOCH.to_owned(), + SIMULATED_MICROLAB.to_owned(), + ]) + ); + + let resumed = run_execution_plan( + &loaded, + ExecutionRunConfig { + assume_yes: true, + resume: true, + mode: ExecutionMode::Simulation, + }, + &mut registry, + &mut AutoOperator { answer: true }, + &mut RecordingSink::default(), + &FixedClock, + ) + .unwrap(); + assert_eq!( + resumed, + ExecutionOutcome::Completed { + executed: 0, + skipped: 5, + started_at_unix_seconds: 1_725_000_000, + ended_at_unix_seconds: 1_725_000_000, + } + ); +} + +fn materialize_reviewed_simulation() -> tempfile::TempDir { + let directory = tempfile::tempdir().unwrap(); + fs::create_dir_all(directory.path().join("adapters")).unwrap(); + fs::create_dir_all(directory.path().join("runs")).unwrap(); + + let examples = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples/ebef"); + let mut inventory = fs::read_to_string(examples.join("inventory/ebef.ttl")).unwrap(); + inventory.push('\n'); + inventory.push_str( + &fs::read_to_string(examples.join("acceptance/simulation-extension.ttl")).unwrap(), + ); + InventoryDocument::read(&inventory, RdfFormat::Turtle) + .unwrap() + .check() + .unwrap(); + fs::write(directory.path().join("inventory-source.ttl"), &inventory).unwrap(); + fs::write(directory.path().join("adapters/lab-simulator.toml"), b"").unwrap(); + + let runs = [ + ( + "liquid-handling.simulation.json", + "liquid-handling", + "Simulate anaerobic plate preparation", + LIQUID_HANDLING, + ), + ( + "incubation.simulation.json", + "incubation", + "Simulate plate growth", + INCUBATION, + ), + ( + "absorbance.simulation.json", + "absorbance", + "Simulate absorbance acquisition", + ABSORBANCE_MEASUREMENT, + ), + ]; + let reviewed = runs + .into_iter() + .map(|(file, id, title, capability)| { + let document = SimulationRunDocument { + format: SIMULATION_RUN_FORMAT.to_owned(), + id: id.to_owned(), + title: title.to_owned(), + capability_kind: capability.to_owned(), + assumptions: vec![ + "No physical EBEF hardware is contacted.".to_owned(), + "The step establishes architecture and provenance behavior only.".to_owned(), + ], + }; + let path = format!("runs/{file}"); + let bytes = write_json(&directory.path().join(&path), &document); + ( + capability, + ReviewedRunDocument { + path, + format: SIMULATION_RUN_FORMAT.to_owned(), + sha256: sha256_hex(&bytes), + }, + ) + }) + .collect::>(); + + let plan = ExecutionPlanDocument { + format: EXECUTION_PLAN_FORMAT.to_owned(), + inventory: ExecutionInventoryReference { + document: "inventory-source.ttl".to_owned(), + source_sha256: sha256_hex(inventory.as_bytes()), + facility: FACILITY.to_owned(), + }, + requirements: vec![ + requirement( + "assay/liquid-handling", + LIQUID_HANDLING, + "https://example.org/ebef-acceptance/microlab_prep_simulator/liquid_handling", + SIMULATED_MICROLAB, + ), + requirement( + "assay/incubation", + INCUBATION, + "https://example.org/ebef-acceptance/epoch_2_simulator/incubation", + SIMULATED_EPOCH, + ), + requirement( + "assay/absorbance", + ABSORBANCE_MEASUREMENT, + "https://example.org/ebef-acceptance/epoch_2_simulator/absorbance_measurement", + SIMULATED_EPOCH, + ), + ], + materials: vec![ExecutionMaterialBinding { + id: "assay_plate".to_owned(), + component: ASSAY_COMPONENT.to_owned(), + material_lot: ASSAY_LOT.to_owned(), + }], + outputs: Vec::new(), + lowerings: Vec::new(), + nodes: vec![ + ExecutionPlanNode { + id: "move-to-liquid-handler".to_owned(), + after: Vec::new(), + action: ExecutionPlanAction::MoveMaterial { + material: "assay_plate".to_owned(), + from: "https://example.org/ebef/microbiology_lab".to_owned(), + to: SIMULATED_MICROLAB.to_owned(), + instructions: "Place the simulated assay plate at the liquid-handler twin." + .to_owned(), + }, + }, + execute_node( + "simulate-liquid-handling", + &["move-to-liquid-handler"], + "assay/liquid-handling", + reviewed.get(LIQUID_HANDLING).unwrap().clone(), + ), + ExecutionPlanNode { + id: "move-to-reader".to_owned(), + after: vec!["simulate-liquid-handling".to_owned()], + action: ExecutionPlanAction::MoveMaterial { + material: "assay_plate".to_owned(), + from: SIMULATED_MICROLAB.to_owned(), + to: SIMULATED_EPOCH.to_owned(), + instructions: "Move the simulated assay plate between the two exact twins." + .to_owned(), + }, + }, + execute_node( + "simulate-incubation", + &["move-to-reader"], + "assay/incubation", + reviewed.get(INCUBATION).unwrap().clone(), + ), + execute_node( + "simulate-absorbance", + &["simulate-incubation"], + "assay/absorbance", + reviewed.get(ABSORBANCE_MEASUREMENT).unwrap().clone(), + ), + ], + }; + write_json(&directory.path().join(EXECUTION_PLAN_FILE), &plan); + directory +} + +fn requirement( + instance: &str, + capability: &str, + offering: &str, + asset: &str, +) -> ExecutionRequirementBinding { + ExecutionRequirementBinding { + requirement_instance: instance.to_owned(), + requirement_template: format!("ebef-acceptance::{instance}"), + capability_kind: capability.to_owned(), + offering: offering.to_owned(), + asset: asset.to_owned(), + minimum_qualification: Qualification::Simulatable.iri().to_owned(), + observed_qualification: Qualification::Simulatable.iri().to_owned(), + control_mode: ControlMode::ReviewedFile.iri().to_owned(), + parameters: Vec::new(), + adapter: Some(ExecutionAdapterBinding { + driver: SIMULATOR.to_owned(), + profile_path: "adapters/lab-simulator.toml".to_owned(), + profile_sha256: sha256_hex(b""), + }), + } +} + +fn execute_node( + id: &str, + after: &[&str], + requirement: &str, + document: ReviewedRunDocument, +) -> ExecutionPlanNode { + ExecutionPlanNode { + id: id.to_owned(), + after: after.iter().map(|id| (*id).to_owned()).collect(), + action: ExecutionPlanAction::Execute { + requirement: requirement.to_owned(), + document: Some(document), + }, + } +} + +fn simulation_registry() -> ExecutorRegistry { + let mut registry = ExecutorRegistry::new(); + for asset in [SIMULATED_MICROLAB, SIMULATED_EPOCH] { + registry + .register( + asset, + SIMULATOR, + SIMULATION_RUN_FORMAT, + Box::::default(), + ) + .unwrap(); + } + registry +} + +fn write_json(path: &Path, value: &T) -> Vec { + let mut bytes = serde_json::to_vec_pretty(value).unwrap(); + bytes.push(b'\n'); + fs::write(path, &bytes).unwrap(); + bytes +} + +fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} diff --git a/crates/lab-sbol/src/read.rs b/crates/lab-sbol/src/read.rs index 0315576..6473d81 100644 --- a/crates/lab-sbol/src/read.rs +++ b/crates/lab-sbol/src/read.rs @@ -168,9 +168,10 @@ fn declaration_for( }; let kind = kind_of(component, kinds, &identity)?; - // The registry's own IRI, stated the way a supplier's catalogue number is, - // so an import stays resolvable back to where it came from. - let mut members = vec![property("identity", string(&identity))]; + // The registry's Component IRI is biological design identity. It is not a + // supplier order number and remains the same whether a laboratory builds + // or buys a realization of the design. + let mut members = vec![property("sbol_identity", string(&identity))]; if let Some(sequence) = sequence_expression(document, component, &identity)? { members.push(property("sequence", sequence)); } @@ -630,7 +631,7 @@ mod tests { let CheckedDeclaration::Catalog { r#type, - identity, + sbol_identity, doc, .. } = catalogued(&module, "BBa_J23101") @@ -638,7 +639,10 @@ mod tests { panic!("catalogued"); }; assert_eq!(r#type.to_string(), "Promoter"); - assert_eq!(identity, "https://synbiohub.org/public/igem/BBa_J23101"); + assert_eq!( + sbol_identity.as_deref(), + Some("https://synbiohub.org/public/igem/BBa_J23101") + ); assert_eq!(doc.as_deref(), Some("constitutive promoter")); // The module publishes what it read, so a later `use` resolves it. @@ -676,7 +680,7 @@ mod tests { panic!("catalogued"); }; let names: Vec<&str> = properties.iter().map(|p| p.name.as_str()).collect(); - assert_eq!(names, vec!["identity", "sequence"]); + assert_eq!(names, vec!["sequence"]); } /// The point of building declarations rather than checked IR: the checker @@ -720,7 +724,7 @@ mod tests { panic!("catalogued"); }; let names: Vec<&str> = properties.iter().map(|p| p.name.as_str()).collect(); - assert_eq!(names, vec!["identity", "sequence", "components"]); + assert_eq!(names, vec!["sequence", "components"]); let components = properties .iter() diff --git a/docs/README.md b/docs/README.md index 2fed68f..4fd7b47 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,7 +33,7 @@ Decision records preserve the reasoning and status behind the language rather th | [0001: Minimal language kernel](language/decisions/0001-language-kernel.md) | indentation for behavior, braces for data, `=` for pure work, `<-` for durable effects | | [0002: Reactive durable workflows](language/decisions/0002-reactive-workflows.md) | deterministic state machines driven by recorded actions, timers, and events | | [0003: Modules and packages](language/decisions/0003-modules-and-packages.md) | whole-module imports and conventional project organization | -| [0004: Portable module IR](language/decisions/0004-portable-module-ir.md) | a typed frontend boundary before target selection and execution | +| [0004: Portable module IR](language/decisions/0004-portable-module-ir.md) | a typed frontend boundary before facility allocation and execution | | [0005: Explicit workflow state](language/decisions/0005-explicit-workflow-state.md) | immutable ordinary bindings and explicit durable mutation | | [0006: Affine material flow](language/decisions/0006-affine-material-flow.md) | one owning place for each physical material, checked across control flow | | [0007: Toolchain CLI boundary](language/decisions/0007-toolchain-cli-boundary.md) | `lab` for working with Lab; `labc` and `lab-opt` for compiler internals | @@ -43,7 +43,7 @@ Decision records preserve the reasoning and status behind the language rather th | [0011: Dependencies from material dataflow](language/decisions/0011-dependencies-from-material-dataflow.md) | build graphs derived from checked workflow values rather than biological level labels | | [0012: Named workflow results](language/decisions/0012-named-workflow-results.md) | explicit named result fields and direct comma-separated returns without synthetic wrapper records | | [0013: Strain artifacts](language/decisions/0013-strain-artifacts.md) | engineered organisms as first-class artifacts rather than a host property on a plasmid | -| [0014: Target profiles and workspaces](language/decisions/0014-target-profiles-and-workspaces.md) | benches configured by target profile, science stated in source, packages grouped by workspace | +| [0014: Target profiles and workspaces](language/decisions/0014-target-profiles-and-workspaces.md) | historical target-profile design, now retained only for its workspace decision | | [0015: Roles classify types](language/decisions/0015-roles-classify-types.md) | types gain capabilities through declared roles rather than hardcoded bounds | | [0016: Callable circuit signatures](language/decisions/0016-callable-circuit-signatures.md) | circuits declare callable signatures with inline type parameters | | [0017: Forgotten type arguments](language/decisions/0017-forgotten-type-arguments.md) | a type argument may be deliberately forgotten with `any Role` | @@ -58,9 +58,9 @@ Decision records preserve the reasoning and status behind the language rather th | [0026: Lineage and replicates](language/decisions/0026-lineage-and-replicates.md) | replicate class is lineage recovered from dataflow, not a property | | [0027: Provenance is stated per thing](language/decisions/0027-provenance-is-stated-per-thing.md) | provenance is a fact about a thing, not about its type | | [0028: Schemas are contributed to](language/decisions/0028-schemas-are-contributed-to.md) | several packages describe one artifact kind | -| [0029: Backend dispatch](language/decisions/0029-backend-dispatch.md) | a profile's `backend` key selects its backend; a registry stays deferred | +| [0029: Backend dispatch](language/decisions/0029-backend-dispatch.md) | superseded historical direct-target backend dispatch | | [0030: Reviewed frames are the execution boundary](language/decisions/0030-reviewed-frames-are-the-execution-boundary.md) | the runtime interprets reviewed run documents and never plans | -| [0031: Workcell targets](language/decisions/0031-workcell-targets.md) | a workcell target composes stations; assignment is planning, not language | +| [0031: Workcell targets](language/decisions/0031-workcell-targets.md) | superseded historical design for compiler-specific multi-device composition | | [0032: Provenance blocks](language/decisions/0032-provenance-blocks.md) | a provenance verb can open a block | | [0033: Typeset protocol documents](language/decisions/0033-typeset-protocol-documents.md) | protocol documents are typeset PDFs emitted beside their sources | | [0039: Roles carry ontology terms](language/decisions/0039-roles-carry-ontology-terms.md) | roles may name the ontology terms they stand for, grounding Lab types in shared vocabularies | @@ -68,11 +68,12 @@ Decision records preserve the reasoning and status behind the language rather th | [0041: Typed SBOL authoring separates design from provenance](language/decisions/0041-typed-sbol-authoring-separates-design-from-provenance.md) | typed Python designs preserve biological kinds while explicit declarations state build or buy provenance | | [0042: Robotics incubates separately](language/decisions/0042-robotics-incubates-separately.md) | simulation, visualization, embodied robotics, and their compute control plane live in the robotics repository | | [0043: Sequences are first-class design values](language/decisions/0043-sequences-are-first-class-design-values.md) | DNA and protein sequences are independent typed values referenced by designs | +| [0044: Facility graphs replace workcell targets](language/decisions/0044-facility-graphs-replace-workcell-targets.md) | SBOLInventory facilities compose exact capability, Asset, material, plan, and run bindings | ## Implementation and embedding - [LAIR overview](../crates/lab-compiler/src/lair/dialect/README.md) introduces the multi-layer intermediate representation used to lower biological intent toward laboratory execution. -- [Protocol IR](../crates/lab-compiler/src/lair/dialect/protocol/README.md) describes the current target-selected biological-procedure boundary and what deliberately remains for resource and hardware lowering. +- [Protocol IR](../crates/lab-compiler/src/lair/dialect/protocol/README.md) describes the selected biological-procedure boundary and what deliberately remains for facility allocation and hardware lowering. - [Compiler internals](../crates/lab-compiler/README.md) describes the current compiler pipeline and developer commands. - [Language frontend](../crates/lab-language/README.md) describes the source-preserving and checked frontend boundaries. - [Project CLI](../crates/lab-cli/README.md) documents the current `lab` project loop. diff --git a/docs/integrations/opentrons-build.md b/docs/integrations/opentrons-build.md index 7ff0f02..a2fd7a8 100644 --- a/docs/integrations/opentrons-build.md +++ b/docs/integrations/opentrons-build.md @@ -4,26 +4,26 @@ This tutorial is a narrow lowering from explicit Lab source into one OT-2 use-ca ## Architectural boundary -The source declares plasmid and strain artifacts with checked properties, declares bought items with `buy` to associate typed symbols with external inventory identities, then composes typed `realize` and `transform` effects in workflows. Dependencies are `Material` values flowing into those effects. The dependency planner derives graph roots and build waves from checked workflow IR without biological level names. Generic compiler responsibilities stop at resolving library operations, type checking, ownership checking, and preserving that dataflow. +The source declares plasmid and strain artifacts with checked properties, gives built and bought designs exact `sbol_identity` values, keeps supplier order identifiers separate, then composes typed `realize` and `transform` effects in workflows. Dependencies are `Material` values flowing into those effects. The dependency planner derives graph roots and build waves from checked workflow IR without biological level names. Generic compiler responsibilities stop at resolving library operations, type checking, ownership checking, and preserving that dataflow. `lab-language` does not contain an OT-2 recipe AST or a build-specific parser entry point. Source declarations are lowered into verifier-valid Design and Workflow LAIR before a concrete biological protocol is selected. The OT-2 backend accepts only `ProtocolLairProgram`; it has no API that can consume `CheckedModule` or `PortableLairProgram` directly. -The implementation has two mandatory target-neutral LAIR boundaries and one explicit robot plan: +The implementation has two mandatory facility-independent LAIR boundaries and one explicit robot plan: 1. Design LAIR contains declarative artifact identity, sequence, topology, copy, and acceptance intent. It contains no build recipe or procedure fields. 2. Workflow LAIR preserves source operations as typed material dataflow. `workflow.realize` owns abstract assembly inputs, artifact dependency identities, assembly policy, and reaction chemistry; `workflow.transform` realizes a strain from its chassis and carried plasmids; subsequent operations carry recovery, dilution, and plating intent on explicit use-def edges. Chemistry travels as a named dictionary rather than one attribute per reagent, so a recipe stays inspectable without the dialect growing a key per volume. 3. Protocol LAIR is produced by a Pliron dialect conversion. It selects synthesis, Golden Gate assembly, provision, transformation, recovery, serial dilution, and selective plating operations, replaces Workflow values with Protocol values, erases Workflow operations, verifies the resulting module, and runs material-linearity analysis. -4. `Ot2ExecutionPlan` is the backend-owned, validated, and resource-allocated robot plan, including source wells, reaction wells, DNA-plate wells, transformation mappings, dilution wells, and plating wells. It carries the target profile it was allocated against, so every projection reads one deck. +4. `Ot2ExecutionPlan` is the adapter-owned, validated, and resource-allocated robot plan, including source wells, reaction wells, DNA-plate wells, transformation mappings, dilution wells, and plating wells. It carries the operational profile frozen for the exact allocated Asset, so every projection reads one deck. The JSON manifest, Markdown instructions, and all three Python protocols are projections of the same `Ot2ExecutionPlan`. This prevents an emitter from independently reconstructing or changing the robot plan. Robot-specific rendering stays under `crates/lab-compiler/src/backend/opentrons/ot2/`. -Labware, deck slots, modules, pipettes, mounts, API level, and per-stage capacity come from a target profile rather than from constants. `profile.rs` parses and validates one: it rejects a slot an OT-2 does not address, a slot the installed thermocycler already occupies, two pieces of labware claiming one slot during a stage, and any key it does not recognize. Every field defaults to the reference bench, so a profile states only what differs. +Labware, deck slots, modules, pipettes, mounts, API level, and per-stage capacity currently come from the allocated OT-2 adapter's checked operational profile rather than constants. `profile.rs` rejects a slot an OT-2 does not address, a slot the installed thermocycler already occupies, two pieces of labware claiming one slot during a stage, and any key it does not recognize. The profile cannot select the adapter or Asset; those are exact facility-plan bindings. Declaring more than one slot for a plate raises the batch size a bench holds. Well addresses are plate-and-well pairs, and allocation fills each declared plate in turn. Robot behavior is maintained as a pinned Python project under `backend/opentrons/ot2/python/`. Its protocol modules import the shared `Ot2ExecutionPlan` `TypedDict` unconditionally and pass Ruff, strict mypy, and pytest checks. Rust does not assemble Python operations: it includes those checked source files, replaces the type-module import with the same marked type definitions, and injects the serialized execution plan. This deterministic bundling step produces the standalone Python file required by the robot without sacrificing normal Python tooling in the source tree or emitted package. -Artifact graph resolution is a separate compiler planning concern. The package compiler projects dependency edges and material requirements directly from verified Protocol operations; `crates/lab-compiler/src/planning/` resolves roots, inventory hits, cycles, blockers, and build waves without knowing anything about plasmids, Golden Gate, or robots. The OT-2 planner then specializes only the generated nodes by selecting their Protocol artifact identities. It never constructs a parallel OT-2 biological recipe IR. +Artifact graph resolution is a separate compiler planning concern. The package compiler projects dependency edges and material requirements directly from verified Protocol operations; `lab-inventory` loads and validates the package's SBOLInventory graph, then passes the compiler an immutable exact-IRI lot index so RDF and filesystem concerns do not enter portable or wasm compilation. `crates/lab-compiler/src/planning/` joins each requirement's checked Component IRI to active MaterialLots in the selected facility, refuses ambiguity, freezes exact lot bindings plus inventory provenance, and resolves roots, cycles, blockers, and build waves without knowing anything about plasmids, Golden Gate, or robots. The OT-2 planner then specializes only generated nodes by selecting their Protocol artifact identities. It never constructs a parallel OT-2 biological recipe IR. The OT-2 specialization selects the concrete realization used by this tutorial: @@ -33,7 +33,7 @@ The OT-2 specialization selects the concrete realization used by this tutorial: - serial dilution for `dilute`; and - selective plating for `plate`. -If source omits or misorders a required material transition, Workflow verification fails before Protocol selection. Other laboratory profiles can provide another Workflow-to-Protocol conversion, while another robot backend can consume the same verified Protocol operations and implement its own execution plan. +If source omits or misorders a required material transition, Workflow verification fails before Protocol selection. Another facility can bind compatible offerings to different adapters, while those adapters consume the same verified Protocol operations and implement their own device plans. ## Generated package @@ -43,7 +43,7 @@ Artifacts in one wave have no ordering constraint between them, so a wave is a s The implementation validates each design's reaction balance against its own stated volume, replicate and dilution bounds, plate capacity across every declared slot, source-rack capacity, and tip capacity. Generated Python is exercised with the official Opentrons simulator. -Run `scripts/check-opentrons-target.sh ` to lint and typecheck the maintained Python target and every emitted protocol, followed by `scripts/simulate-opentrons.sh ` for Opentrons simulation. +Run `scripts/check-opentrons-bundle.sh ` to lint and typecheck the maintained Python adapter and every emitted protocol, followed by `scripts/simulate-opentrons.sh ` for Opentrons simulation. ## Opening a protocol in the Opentrons app @@ -51,4 +51,4 @@ Emitted protocols declare `robotType: "OT-2"`. Opentrons moved OT-2 support into ## Current boundary -This spike does not yet query a live inventory service, resolve inventory lots, ingest SBOL, design compatible overhangs, normalize source concentrations, prepare DNA between dependent waves, or attach runtime evidence to acceptance decisions. Generated instructions and robot code require laboratory review and qualification before physical execution. +This specialization now ingests a packaged SBOLInventory document and freezes unique active MaterialLot bindings by exact `sbol:built` Component identity. It does not query a live inventory service, reserve or allocate among several candidate lots, reason over quantity or expiration, design compatible overhangs, normalize source concentrations, prepare DNA between dependent waves, or attach runtime evidence to acceptance decisions. Generated instructions and robot code require laboratory review and qualification before physical execution. diff --git a/docs/language/README.md b/docs/language/README.md index 13c3e6c..261bc96 100644 --- a/docs/language/README.md +++ b/docs/language/README.md @@ -6,6 +6,7 @@ The documents have distinct jobs: - `syntax.md` records accepted surface-language rules; - `semantics.md` records the meaning of laboratory values and effects; +- `capabilities.md` records stable capability IRIs, the standard-action audit, and requirement matching rules; - `generics.md` records how type parameters, roles, generic kinds, and unit types fit together; - `modules.md` records package imports and idiomatic source organization; - `open-questions.md` keeps unresolved design choices visible; @@ -31,7 +32,7 @@ Actual executions are runtime records, not source modules. A program may be run | [`plasmid-design.lab`](specimens/plasmid-design.lab) | circuits, typed composition, declarative plasmid properties, requirements, and acceptance | | [`sensor-panel.lab`](specimens/sensor-panel.lab) | roles, inline type parameters, a generic characterization workflow, and a panel that forgets which signal triggers it | | [`plasmid-build.lab`](specimens/plasmid-build.lab) | workflow signatures, durable effects, explicit state, reactive handlers, outcomes, and affine materials | -| [`inventory-plasmid.lab`](specimens/inventory-plasmid.lab) | typed inventory identities, heterogeneous component lists, target-neutral properties, and one realization workflow | +| [`inventory-plasmid.lab`](specimens/inventory-plasmid.lab) | typed inventory identities, heterogeneous component lists, facility-independent properties, and one realization workflow | | [`dependency-build.lab`](specimens/dependency-build.lab) | dependencies expressed as `Material` workflow inputs and resolved `realize` operands | Specimens define provider symbols before declarations that depend on them. This is a readability convention, not an assembly-level system and not a replacement for name resolution. @@ -52,7 +53,7 @@ labc docs/language/specimens/inventory-plasmid.lab --emit module-ir labc docs/language/specimens/dependency-build.lab --emit module-ir ``` -Portable module compilation resolves and checks the program but does not select a laboratory target, schedule work, or dispatch physical actions. +Portable module compilation resolves and checks the program but does not select a facility Asset, schedule work, or dispatch physical actions. The latest accepted design records are: @@ -61,7 +62,7 @@ The latest accepted design records are: - [`0011`](decisions/0011-dependencies-from-material-dataflow.md): dependency graphs derived from checked material dataflow; - [`0012`](decisions/0012-named-workflow-results.md): named typed workflow results and direct multi-value returns; - [`0013`](decisions/0013-strain-artifacts.md): engineered organisms as first-class artifacts; -- [`0014`](decisions/0014-target-profiles-and-workspaces.md): target profiles for benches and workspaces for packages; +- [`0014`](decisions/0014-target-profiles-and-workspaces.md): historical target profiles and the retained workspace design; - [`0015`](decisions/0015-roles-classify-types.md): roles classify types, and a role is not a type; - [`0016`](decisions/0016-callable-circuit-signatures.md): circuits declare callable signatures with inline type parameters; - [`0017`](decisions/0017-forgotten-type-arguments.md): a type argument may be deliberately forgotten; @@ -76,13 +77,14 @@ The latest accepted design records are: - [`0026`](decisions/0026-lineage-and-replicates.md): replicate class is lineage, not a property; - [`0027`](decisions/0027-provenance-is-stated-per-thing.md): provenance is a fact about a thing, not about its type; - [`0028`](decisions/0028-schemas-are-contributed-to.md): several packages describe one kind; -- [`0029`](decisions/0029-backend-dispatch.md): a profile's backend key selects its backend; +- [`0029`](decisions/0029-backend-dispatch.md): superseded historical direct-target backend dispatch; - [`0030`](decisions/0030-reviewed-frames-are-the-execution-boundary.md): reviewed frames are the execution boundary; -- [`0031`](decisions/0031-workcell-targets.md): a workcell target composes stations, and assignment is planning rather than language; +- [`0031`](decisions/0031-workcell-targets.md): superseded historical design for compiler-specific multi-device composition; - [`0032`](decisions/0032-provenance-blocks.md): a provenance verb can open a block; - [`0033`](decisions/0033-typeset-protocol-documents.md): protocol documents are typeset PDFs; - [`0039`](decisions/0039-roles-carry-ontology-terms.md): a role may name the ontology term it stands for; - [`0040`](decisions/0040-networks-are-lists-of-transcription-units.md): a genetic network is a list of typed transcription units; and - [`0041`](decisions/0041-typed-sbol-authoring-separates-design-from-provenance.md): typed Python SBOL authoring keeps biological design separate from build and buy provenance; - [`0042`](decisions/0042-robotics-incubates-separately.md): simulation, visualization, embodied robotics, and related compute infrastructure incubate outside Lab; and -- [`0043`](decisions/0043-sequences-are-first-class-design-values.md): DNA and protein sequences are independent typed values referenced by designs. +- [`0043`](decisions/0043-sequences-are-first-class-design-values.md): DNA and protein sequences are independent typed values referenced by designs; and +- [`0044`](decisions/0044-facility-graphs-replace-workcell-targets.md): SBOLInventory facilities replace compiler-specific workcell targets with exact capability, Asset, material, plan, and run bindings. diff --git a/docs/language/capabilities.md b/docs/language/capabilities.md new file mode 100644 index 0000000..5d86fd3 --- /dev/null +++ b/docs/language/capabilities.md @@ -0,0 +1,46 @@ +# Capability requirements + +Every durable Lab action carries one absolute capability-kind IRI in checked portable IR. The IRI identifies semantic work required by the workflow; it does not identify a device, driver, asset, or product model. Facility planning compares these requirements with exact `fac:capabilityKind` values on SBOLInventory capability offerings. + +SBOLInventory Profile 0.2 deliberately keeps capability kinds open. Lab uses the profile's normative terms where they fit exactly and uses explicit terms in the same capability namespace where the current vocabulary has a gap. An extension term is a stable exact IRI, but it is not represented as a Profile 0.2 vocabulary term until it is contributed upstream. Lab never substitutes a local abbreviation or guesses equivalence from an IRI suffix. + +## Standard-action audit + +| Operation | Capability kind | Profile 0.2 status | +| --- | --- | --- | +| `std.bio.build.realize` | `https://sbol.io/ns/capability#ArtifactRealization` | open extension; abstract requirement that must be refined before asset allocation | +| `std.lab.plasmid.capture` | `https://sbol.io/ns/capability#PlateImaging` | open extension | +| `std.lab.plasmid.synthesize` | `https://sbol.io/ns/capability#DnaSynthesis` | open extension | +| `std.lab.plasmid.assemble` | `https://sbol.io/ns/capability#DnaAssembly` | open extension | +| `std.lab.plasmid.provision` | `https://sbol.io/ns/capability#MaterialProvisioning` | open extension | +| `std.lab.plasmid.transform` | `https://sbol.io/ns/capability#ChemicalTransformation` | open extension | +| `std.lab.plasmid.recover` | `https://sbol.io/ns/capability#Incubation` | Profile 0.2 vocabulary | +| `std.lab.plasmid.dilute` | `https://sbol.io/ns/capability#LiquidHandling` | Profile 0.2 vocabulary | +| `std.lab.plasmid.plate` | `https://sbol.io/ns/capability#AntibioticSelection` | open extension | +| `std.lab.plasmid.pick` | `https://sbol.io/ns/capability#ColonyPicking` | open extension | +| `std.lab.plasmid.screen` | `https://sbol.io/ns/capability#CloneScreening` | open extension | +| `std.lab.plasmid.grow` | `https://sbol.io/ns/capability#Incubation` | Profile 0.2 vocabulary | +| `std.lab.plasmid.purify` | `https://sbol.io/ns/capability#PlasmidPurification` | open extension | +| `std.lab.plasmid.split` | `https://sbol.io/ns/capability#LiquidHandling` | Profile 0.2 vocabulary | +| `std.lab.plasmid.sequence` | `https://sbol.io/ns/capability#SangerSequencing` | open extension | +| `std.lab.plasmid.quantify` | `https://sbol.io/ns/capability#DnaQuantification` | open extension | +| `std.lab.plasmid.store` | `https://sbol.io/ns/capability#ColdStorage` | Profile 0.2 vocabulary | +| `std.lab.plasmid.dispose` | `https://sbol.io/ns/capability#WasteHandling` | open extension | + +The audit does not assert that every source action maps directly to one instrument operation. `ArtifactRealization`, `DnaAssembly`, `ChemicalTransformation`, and similar biological requirements may refine into several operational requirements such as liquid handling, thermal cycling, incubation, transport, or manual work. Requirement refinement must preserve the parent requirement and source-action identity so a reviewed plan can explain why each allocated offering is present. + +## Compiler requirement IR + +`lab build` emits `capability_requirements.json` with schema `lab.capability-requirements.v2` and links it from the portable package index. Each requirement template has a deterministic ID and exact source module, workflow, statement path, and operation. It records the capability-kind IRI, a typed minimum qualification, a typed set of accepted SBOLInventory control modes, exact typed scalar constraints, typed design or data value ports, and typed material inputs and outputs with ownership modes. Every operational parameter names an absolute `fac:propertyKind` IRI, and every quantity also carries a canonical QUDT unit IRI, so allocation never joins RDF facts through source argument names or unit abbreviations. + +Runnable packages also emit `capability_instances.json` with schema `lab.capability-requirement-instances.v2`, which records the exact requirement-catalog schema it references. The compiler begins at the exact module named by `build.entry` and its `main` workflow, follows resolved workflow declaration identities across package boundaries, and emits one instance for each reachable call path. Calling one workflow twice creates two distinct instances. Uncalled workflow templates remain portable but are not allocated. Structural branches and loops are retained conservatively as potential work, while recursive workflow expansion is rejected because it cannot yield a finite reviewed plan. + +These records describe workflow definitions. A workflow call does not duplicate its callee's template, and an unused workflow remains visible as a reusable template. Facility planning must instantiate only the workflows reached by the selected program, preserve call and refinement ancestry, and then allocate the resulting operational requirements. The root templates deliberately contain no Asset or CapabilityOffering IRI. + +## Matching rules + +Capability matching is exact IRI equality. Qualification, control mode, typed parameters, material compatibility, containment, capacity, and configured adapter availability are separate predicates. Candidate order is deterministic for review but never constitutes allocation. + +The facility phase run automatically by a facility-configured `lab build`, or separately by `lab plan`, emits `lab.facility-allocation.v1` and then projects it into the reviewed `lab.execution-plan.v1` document. Each allocation is `Requirement instance -> CapabilityOffering IRI -> Asset IRI` and records the required and observed qualification, control mode, exact matched parameters, rejected candidates, and an optional exact adapter/profile hash. The generic plan freezes the inventory source hash and Facility IRI, requirement bindings, optional Component-to-MaterialLot bindings, and a dependency DAG of `Execute`, `MoveMaterial`, and `Manual` nodes. A child device document is optional for a planning-only plan, but when present its path, format, and SHA-256 must agree with the frozen adapter contract. + +An adapter declaration is resolved independently of workflow allocation. `lab.adapter-bindings.v2` joins its exact Asset IRI to only those owned CapabilityOfferings whose capability-kind and control-mode IRIs the declared driver supports, records each offering's exact typed parameters, then records effective activity and separate planning, simulation, and execution eligibility. A Plannable offering does not become Executable because a runtime adapter exists, and an Executable offering does not become operable unless a configured adapter supports its exact control mode. diff --git a/docs/language/decisions/0004-portable-module-ir.md b/docs/language/decisions/0004-portable-module-ir.md index 7143d2d..7bf9d24 100644 --- a/docs/language/decisions/0004-portable-module-ir.md +++ b/docs/language/decisions/0004-portable-module-ir.md @@ -4,10 +4,10 @@ Status: accepted, initial implementation Complete Lab modules compile first to verified portable module IR. This boundary contains resolved imports, checked declaration properties, structured nominal and union types, typed expression trees, explicit workflow signatures and state, resolved operation identities, action capabilities and operand ownership modes, checked outcome constructors, control-flow continuation, and reactive handler structure. -Portable module IR is intentionally above laboratory target selection. Producing verified module IR means frontend type, return, and affine material-flow checks have passed for the supported control-flow subset. It does not mean that a workflow has been scheduled, that a target can lower every operation or property, or that any command has been durably dispatched. Those are later compiler and runtime boundaries and must have distinct outputs. +Portable module IR is intentionally independent of any facility. Producing verified module IR means frontend type, return, and affine material-flow checks have passed for the supported control-flow subset. It does not mean that a workflow has been scheduled, that a facility contains qualified offerings and configured adapters for every requirement, or that any command has been durably dispatched. Those are later planning and runtime boundaries and must have distinct outputs. `CheckedModule` is the sole source-compilation boundary. Source lowering consumes it exactly once to construct verifier-valid LAIR. Narrow specializations consume LAIR—not `CheckedModule`—and select only the properties and resolved operations they support; output selection never switches to a different source frontend or bypasses LAIR. Lower IRs must preserve the workflow, event, evidence, dependency, and material semantics required by their backend rather than reconstructing them from source text. -Source lowering constructs Design and Workflow LAIR. Design operations are strictly declarative and do not carry procedure data. Workflow operations preserve source-level abstract actions, material ownership edges, artifact dependencies, and requested policy. A profile-selected Pliron dialect conversion replaces Workflow operations and values with verifier-valid Protocol LAIR; Protocol material linearity is checked before a robot backend can consume the result. +Source lowering constructs Design and Workflow LAIR. Design operations are strictly declarative and do not carry procedure data. Workflow operations preserve source-level abstract actions, material ownership edges, artifact dependencies, and requested policy. A method-selection Pliron dialect conversion replaces Workflow operations and values with verifier-valid Protocol LAIR; Protocol material linearity is checked before facility allocation and adapter lowering can consume the result. -An execution specialization owns its resource allocation and concrete emitters, but it must not recreate a parallel biological recipe IR. The initial OT-2 backend therefore accepts `ProtocolLairProgram` directly and constructs a validated, resource-allocated `Ot2ExecutionPlan`; its Python, Markdown, and manifest outputs all consume that execution plan. Hardware constants and rendering logic do not belong in the portable AST, checked IR, Design/Workflow/Protocol LAIR, or generic output module. Dependency graph resolution remains separate target-neutral planning because it operates only on artifact edges, required materials, and inventory availability projected from verified Protocol operations. +Facility planning binds requirements to exact capability offerings and Assets before a configured adapter constructs its concrete device plan, and neither layer may recreate a parallel biological recipe IR. The reviewed plan freezes that binding. The initial OT-2 adapter accepts `ProtocolLairProgram` and the checked operational profile for the already-bound Asset, then constructs a validated `Ot2ExecutionPlan`; its Python, Markdown, and manifest outputs all consume that execution plan. Hardware constants and rendering logic do not belong in the portable AST, checked IR, Design/Workflow/Protocol LAIR, or generic output module. Dependency graph resolution remains separate facility-independent planning because it operates only on artifact edges, required materials, and inventory availability projected from verified Protocol operations. diff --git a/docs/language/decisions/0006-affine-material-flow.md b/docs/language/decisions/0006-affine-material-flow.md index 9ced5bd..c8ccbbd 100644 --- a/docs/language/decisions/0006-affine-material-flow.md +++ b/docs/language/decisions/0006-affine-material-flow.md @@ -8,6 +8,6 @@ The analysis is place-sensitive. Taking `colony_result.plate` invalidates that p Every terminating control-flow path must transfer, return, store, or dispose all materials it owns. Continuing branches must agree on their owned material places. Reactive handlers begin from the same captured ownership state; a non-terminating invocation must preserve it for later events. -This frontend analysis complements the existing SSA material-linearity analysis in the executable protocol IR. The frontend pass reasons about source workflow control flow before target selection, while the protocol pass verifies concrete lowered SSA consumers. +This frontend analysis complements the existing SSA material-linearity analysis in method-selected Protocol IR. The frontend pass reasons about source workflow control flow before method selection, while the protocol pass verifies concrete lowered SSA consumers before facility allocation. Loops over collections containing materials are rejected for now. They require an explicit consuming iterator contract that defines ownership for zero, partial, completed, and early-return iteration. diff --git a/docs/language/decisions/0009-declaration-properties-and-workflow-signatures.md b/docs/language/decisions/0009-declaration-properties-and-workflow-signatures.md index 0237b59..aa4d4dc 100644 --- a/docs/language/decisions/0009-declaration-properties-and-workflow-signatures.md +++ b/docs/language/decisions/0009-declaration-properties-and-workflow-signatures.md @@ -12,7 +12,7 @@ plasmid reporter: backbone: pSB1C3 ``` -The source AST and portable module IR preserve this distinction as properties rather than translating properties into ordinary bindings. Property values are typed expressions. Their names remain backend-neutral: target-specific names such as `restriction_enzyme` or `serial_dilutions` are interpreted by a target lowerer, not by parser productions or dedicated core AST fields. +The source AST and portable module IR preserve this distinction as properties rather than translating properties into ordinary bindings. Property values are typed expressions. Their names remain implementation-neutral: method-specific names such as `restriction_enzyme` or `serial_dilutions` are interpreted during method selection, not by parser productions or dedicated core AST fields; adapter configuration remains outside source entirely. Workflow parameters and results form a mandatory callable signature in the declaration header: diff --git a/docs/language/decisions/0011-dependencies-from-material-dataflow.md b/docs/language/decisions/0011-dependencies-from-material-dataflow.md index bd5b2ad..b0fbe91 100644 --- a/docs/language/decisions/0011-dependencies-from-material-dataflow.md +++ b/docs/language/decisions/0011-dependencies-from-material-dataflow.md @@ -1,6 +1,6 @@ # 0011: Artifact dependencies derive from typed material dataflow -Status: accepted, initial target lowering implemented +Status: accepted, initial facility lowering implemented ## Decision @@ -14,10 +14,10 @@ workflow assemble_reporter( product <- realize reporter from dependencies ``` -The workflow signature states what must already exist. The `Material` value is affine, and the `realize` contract takes the dependency list. Checked IR therefore preserves both dependency identity and ownership transfer without target-specific graph annotations in the core language. +The workflow signature states what must already exist. The `Material` value is affine, and the `realize` contract takes the dependency list. Checked IR therefore preserves both dependency identity and ownership transfer without facility-specific graph annotations in the core language. -A target lowerer may derive graph edges, roots, build waves, cycles, retries, and blockers from that checked dataflow. Inventory may satisfy a node without executing its recipe, which cuts the corresponding execution path while leaving the source dependency relation intact. +A facility-independent planner may derive graph edges, roots, build waves, cycles, retries, and blockers from that checked dataflow. Inventory may satisfy a node without executing its recipe, which cuts the corresponding execution path while leaving the source dependency relation intact. ## Boundary -The generic frontend resolves operations, checks types and ownership, and preserves dependency dataflow. It does not select Golden Gate, heat shock, deck layouts, reaction volumes, or any other hardware procedure. Those choices and their constraints belong to a narrow target specialization. A different target may lower the same source operations differently or reject unsupported properties and operation sequences explicitly. +The generic frontend resolves operations, checks types and ownership, and preserves dependency dataflow. Method selection may choose Golden Gate and heat shock while remaining independent of any facility. Facility planning binds the resulting requirements to exact offerings and Assets, after which the bound adapter chooses deck layouts and device operations. A different supported method or compatible adapter may lower the same source operations differently or reject unsupported properties and operation sequences explicitly. diff --git a/docs/language/decisions/0014-target-profiles-and-workspaces.md b/docs/language/decisions/0014-target-profiles-and-workspaces.md index fe2fd96..6d30dbc 100644 --- a/docs/language/decisions/0014-target-profiles-and-workspaces.md +++ b/docs/language/decisions/0014-target-profiles-and-workspaces.md @@ -2,7 +2,7 @@ ## Status -Accepted. +Partially superseded by [0044: Facility graphs and capability binding replace workcell targets](0044-facility-graphs-replace-workcell-targets.md). The workspace decision remains accepted; the independent target-profile selection described below is historical. ## Context diff --git a/docs/language/decisions/0022-fixed-grammar-open-vocabulary.md b/docs/language/decisions/0022-fixed-grammar-open-vocabulary.md index c4c45db..4f74b11 100644 --- a/docs/language/decisions/0022-fixed-grammar-open-vocabulary.md +++ b/docs/language/decisions/0022-fixed-grammar-open-vocabulary.md @@ -73,8 +73,4 @@ error: unknown declaration kind 'reagent' = help: kinds in scope: plasmid, strain ``` -**This removes biology from the frontend only.** The OT-2 backend still reads -`reaction_volume` and `digest_temperature` by name, so biology does not leave -the toolchain until target property contracts exist. What it gains is an honest -failure: a target asked to build a kind it does not know now says so, rather -than the checker refusing the word before a target ever sees it. +**This removes biology from the frontend only.** The initial plasmid-build method still reads `reaction_volume` and `digest_temperature` by name, so biology does not leave the toolchain until method property contracts exist. What it gains is an honest failure: method selection asked to build a kind it does not know now says so, rather than the checker refusing the word before lowering ever sees it. diff --git a/docs/language/decisions/0024-catalogued-items-carry-properties.md b/docs/language/decisions/0024-catalogued-items-carry-properties.md index b45c30c..bdac06a 100644 --- a/docs/language/decisions/0024-catalogued-items-carry-properties.md +++ b/docs/language/decisions/0024-catalogued-items-carry-properties.md @@ -40,5 +40,4 @@ temperature. ## Consequences -`CheckedDeclaration::Catalog` carries the properties, so a target reads an item's -datasheet from the IR rather than from a lookup table of its own. +`CheckedDeclaration::Catalog` carries the properties, so method selection and adapter planning read an item's datasheet from the IR rather than from private lookup tables. diff --git a/docs/language/decisions/0028-schemas-are-contributed-to.md b/docs/language/decisions/0028-schemas-are-contributed-to.md index febbb94..6f9d740 100644 --- a/docs/language/decisions/0028-schemas-are-contributed-to.md +++ b/docs/language/decisions/0028-schemas-are-contributed-to.md @@ -48,8 +48,6 @@ An undeclared property is now a mistake everywhere, reported with the name it most likely meant. That is the guarantee a schema was supposed to carry from the start. -A quantity's unit is checked where it is written rather than when a target reads -the IR, because the field that declares it is typed. +A quantity's unit is checked where it is written rather than when method selection or an adapter reads the IR, because the field that declares it is typed. -A method is a module, so it needs no keyword, no export kind, and no resolution -machinery. Two backends running the same reaction import the same module. +A method is a module, so it needs no keyword, no export kind, and no resolution machinery. Two adapters implementing the same reaction import the same module. diff --git a/docs/language/decisions/0029-backend-dispatch.md b/docs/language/decisions/0029-backend-dispatch.md index 04e8156..a4bc9f5 100644 --- a/docs/language/decisions/0029-backend-dispatch.md +++ b/docs/language/decisions/0029-backend-dispatch.md @@ -2,7 +2,7 @@ ## Status -Accepted. Supersedes the single-backend position of 0014. +Superseded by [0044: Facility graphs and capability binding replace workcell targets](0044-facility-graphs-replace-workcell-targets.md). This records the historical direct-target dispatch design. ## Context diff --git a/docs/language/decisions/0030-reviewed-frames-are-the-execution-boundary.md b/docs/language/decisions/0030-reviewed-frames-are-the-execution-boundary.md index 1ffdc9a..943dd27 100644 --- a/docs/language/decisions/0030-reviewed-frames-are-the-execution-boundary.md +++ b/docs/language/decisions/0030-reviewed-frames-are-the-execution-boundary.md @@ -6,54 +6,20 @@ Accepted. ## Context -The Hamilton STAR is the toolchain's first execution target without an -offline protocol format. The Opentrons backends emit files another vendor's -software validates and runs; the STAR runs a live USB firmware session, and -its "protocol" is the sequence of ASCII command frames the machine is sent. -A backend for it must answer what `lab build` produces, and what stands -between the produced thing and a moving machine. +The Hamilton STAR is the toolchain's first execution adapter without an offline protocol format. The Opentrons adapters emit files another vendor's software validates and runs; the STAR runs a live USB firmware session, and its "protocol" is the sequence of ASCII command frames the machine is sent. An adapter for it must answer what a facility-derived plan produces and what stands between the produced thing and a moving machine. -The driver crate `hamilton-star` already separates the pure protocol -(typed, golden-tested frame encoders) from the transport and session. The -compiler consumes only the pure layer. +The driver crate `hamilton-star` already separates the pure protocol (typed, golden-tested frame encoders) from the transport and session. The compiler consumes only the pure layer. ## Decision -`lab build --target ` emits `lab.star-run.v0` documents: per -stage, an ordered list of id-less firmware frames built by the driver -crate's validated encoders, each carrying an operator-facing description, -with the manual steps (thermal work the base machine cannot do) interleaved -between run documents. The document is the review boundary: `lab run` -replays the frames verbatim, adding only the command ids the session -protocol requires. Nothing is planned, derived, or decided at run time — -what the reviewer read is what the machine receives. - -Planning is deterministic to make that review meaningful. Deck coordinates -come from a vendored, attributed catalog of Hamilton carriers and labware; -liquid heights come from per-well volume tracking over measured -volume-to-height models, with the safety margins stated as named constants; -liquid-class corrected volumes come from the driver crate's water tables. -Capacitive level detection is a per-bench opt-in that adds a runtime check -on top of the planned heights, never a substitute for them. - -`lab run` is the toolchain's first hardware-touching command, so its safety -posture is part of this decision: a dry run that validates and prints every -frame without hardware, an explicit confirmation before any motion, an -operator confirmation at every manual step, and on any firmware error a -Z-safety retract followed by an abort naming the failed step — no automatic -retry, no resume. - -The backend lives at `backend/hamilton/star`, the second vendor family -under the dispatch rule of 0029; profiles declare `backend = -"hamilton.star"`. +When facility allocation selects a Hamilton STAR CapabilityOffering and its exact Asset is bound to the `hamilton.star` adapter, `lab plan` emits `lab.star-run.v0` documents: per stage, an ordered list of id-less firmware frames built by the driver crate's validated encoders, each carrying an operator-facing description, with the manual steps (thermal work the base machine cannot do) interleaved between run documents. The reviewed facility plan binds the run document to the requirement, offering, Asset, adapter, adapter configuration, and artifact digest. `lab run` replays the frames verbatim, adding only the command ids the session protocol requires. Nothing is planned, derived, or decided at run time: what the reviewer read is what the machine receives. + +Planning is deterministic to make that review meaningful. Deck coordinates come from a vendored, attributed catalog of Hamilton carriers and labware; liquid heights come from per-well volume tracking over measured volume-to-height models, with the safety margins stated as named constants; liquid-class corrected volumes come from the driver crate's water tables. Capacitive level detection is an adapter-configuration opt-in that adds a runtime check on top of the planned heights, never a substitute for them. + +`lab run` is the toolchain's first hardware-touching command, so its safety posture is part of this decision: a dry run that validates and prints every frame without hardware, an explicit confirmation before any motion, an operator confirmation at every manual step, and on any firmware error a Z-safety retract followed by an abort naming the failed step, with no automatic retry or resume. + +The implementation lives at `backend/hamilton/star`. SBOLInventory describes the physical Asset and its capability offerings; a local execution overlay binds that exact Asset IRI to the `hamilton.star` adapter. The adapter configuration cannot choose an Asset or adapter. ## Consequences -A protocol reviewer reads real firmware frames, and golden tests pin them -byte for byte — the same discipline the driver crate applies to its -encoders. The run format is versioned like a wire format: a change to what -frames mean is a new format version, not an edit. The compiler builds -everywhere without libusb (the driver crate's `usb` feature stays off in -the workspace dependency and on in the CLI), and executing a plan requires -passing through the emitted, reviewable document — there is no -compile-and-run path that skips the artifact. +A protocol reviewer reads real firmware frames, and golden tests pin them byte for byte, following the same discipline the driver crate applies to its encoders. The run format is versioned like a wire format: a change to what frames mean is a new format version, not an edit. The compiler builds everywhere without libusb (the driver crate's `usb` feature stays off in the workspace dependency and on in the CLI), and executing a plan requires passing through the emitted, reviewable document. There is no compile-and-run path that skips the artifact. diff --git a/docs/language/decisions/0031-workcell-targets.md b/docs/language/decisions/0031-workcell-targets.md index e18d4df..08ef7c6 100644 --- a/docs/language/decisions/0031-workcell-targets.md +++ b/docs/language/decisions/0031-workcell-targets.md @@ -2,7 +2,7 @@ ## Status -Accepted. +Superseded by [0044: Facility graphs and capability binding replace workcell targets](0044-facility-graphs-replace-workcell-targets.md). ## Context diff --git a/docs/language/decisions/0042-robotics-incubates-separately.md b/docs/language/decisions/0042-robotics-incubates-separately.md index e956b33..02823aa 100644 --- a/docs/language/decisions/0042-robotics-incubates-separately.md +++ b/docs/language/decisions/0042-robotics-incubates-separately.md @@ -6,7 +6,7 @@ Accepted. ## Context -Lab's language, compiler, package model, instrument backends, reviewed run documents, and live execution path are established parts of one stack. General workflow simulation, facility models, semantic scenes, photoreal rendering, embodied robot tasks, physics integrations, and remote training compute are much earlier experiments with a different release cadence. +Lab's language, compiler, package model, SBOLInventory facility graph, instrument adapters, reviewed run documents, and execution path are established parts of one stack. General workflow simulation, semantic scenes, photoreal rendering, embodied robot tasks, physics integrations, and remote training compute are much earlier experiments with a different release cadence. Keeping those experiments in this repository made Lab's stable boundaries appear contingent on an immature robotics architecture and expanded its build, test, documentation, and dependency surface. @@ -14,7 +14,7 @@ Keeping those experiments in this repository made Lab's stable boundaries appear Simulation, visualization, embodied robotics, and their compute control plane incubate in the separate [`lab-lang/robotics`](https://github.com/lab-lang/robotics) repository. -Lab retains concrete laboratory automation backends, compiler-owned target profiles, reviewed run-document formats, dry-run review, live instrument execution, and human-confirmed workcell coordination. Robotics may consume those stable outputs across a repository boundary, but Lab does not host robotics-specific formats, commands, assets, viewers, physics adapters, or compute providers. +Lab retains concrete laboratory automation adapters, SBOLInventory facility ingestion, capability allocation, reviewed run-document formats, dry-run review, semantic no-hardware execution, live instrument execution, and human-confirmed facility coordination. Robotics may consume those stable outputs across a repository boundary, but Lab does not host robotics-specific formats, commands, assets, viewers, physics adapters, or compute providers. `lab run --simulate` validates and walks an exact reviewed facility plan without hardware; it is not a physics, scene, or embodied-robotics simulator. ## Consequences diff --git a/docs/language/decisions/0044-facility-graphs-replace-workcell-targets.md b/docs/language/decisions/0044-facility-graphs-replace-workcell-targets.md new file mode 100644 index 0000000..5c47227 --- /dev/null +++ b/docs/language/decisions/0044-facility-graphs-replace-workcell-targets.md @@ -0,0 +1,53 @@ +# 0044: Facility graphs and capability binding replace workcell targets + +## Status + +Accepted. Supersedes [0031: Workcell targets](0031-workcell-targets.md). + +## Context + +The workcell target proved that one workflow may span several instruments and explicit material movements, but it encoded the facility as a compiler-specific list of stations with fixed kinds. That made one target profile responsible for persistent facility facts, capability assignment, execution configuration, and coordination. It could not naturally represent nested locations, material lots, several interchangeable offerings, distinct qualification levels, or a facility that contains more than one liquid handler. + +Lab now depends on the SBOLInventory Profile 0.2 implementation in `sbol-rs`. The profile gives facilities, zones, assets, capability offerings, typed parameters, material lots, and run provenance stable RDF identities without adding those extension classes to core SBOL 3. + +## Decision + +Lab uses this ownership model: + +```text +a facility contains zones +zones locate assets and material lots +assets expose capability offerings +workflows require capabilities +plans bind requirements to offerings and assets +runs record material changes and evidence +``` + +The persistent catalog and run ledger are SBOLInventory graphs. Workflow requirements remain compiler IR. Allocation, scheduling, adapter selection, and dispatch remain Lab concerns. + +A package selects one RDF document through `[inventory].document` and may select one Facility by absolute IRI. If the selector is omitted, the document must contain exactly one Facility. Lab validates both SBOL 3 and SBOLInventory before exposing an immutable inventory snapshot, and a reviewed plan records the exact Facility IRI and source-file SHA-256. + +Workflow operations refine into capability requirements identified by stable absolute capability-kind IRIs, minimum qualification, accepted control modes, typed parameter constraints, and material inputs and outputs. The facility planner binds each reachable requirement to an exact `CapabilityOffering` IRI and its owning `Asset` IRI. Candidate ordering is not allocation, so unresolved equal candidates remain an explained ambiguity. + +Operational configuration is an overlay keyed by exact Asset IRI. An adapter descriptor states the capability kinds, control modes, document formats, and planning, lowering, simulation, or runtime services its implementation supports. Manufacturer and model never select a driver. The `lab.adapter-profile.v2` schema contains no target, backend, or Asset selector: the manifest's exact Asset-to-driver binding selects the implementation, while the profile supplies only its checked non-secret configuration. Endpoints and credentials remain local runtime configuration rather than facility facts. + +The reviewed coordination artifact is `lab.execution-plan.v1`. It freezes inventory, requirement, offering, Asset, MaterialLot, adapter-profile, and reviewed-document hashes in one dependency DAG containing `Execute`, `MoveMaterial`, and `Manual` nodes. Device-specific reviewed formats remain independent child documents. + +When an adapter still lowers a whole program rather than one capability requirement at a time, the plan freezes a reviewed adapter-lowering bundle containing the exact triggering requirements and every emitted artifact path, role, format, and digest. Lab does not assign one bundle protocol arbitrarily to one requirement. Runtime preflight verifies the complete bundle, while its Execute nodes remain planning-only until a requirement-aware adapter can attach independently executable child documents. + +The runtime executes only the frozen bindings through a registry keyed by Asset IRI, adapter ID, and document format. It never re-queries the facility or substitutes an Asset. Its durable ledger is bound to the plan digest, inventory digest, and execution mode. Live and simulation resume state are deliberately incompatible. + +A completed live run writes a new `inventory-after.ttl`; a completed simulation writes `inventory-simulation.ttl`. Both preserve the source graph and add a PROV Activity, exact Asset and input MaterialLot Usages, reviewed evidence Attachments, and timing. Only live execution may generate output MaterialLots. + +The workcell target, station taxonomy, `lab.workcell-run.v0`, workcell runtime, independent single-device target profiles, `[build] target`, `lab build --target`, and `lab targets` are removed. Device backends are reachable through explicit Asset-to-adapter bindings only after facility allocation. + +For a runnable package that selects an inventory document, `lab build` performs that allocation and adapter lowering after portable compilation and writes the reviewed plan beside the portable artifacts. `lab plan` exposes the identical facility phase separately; it is not another target-selection mechanism. + +## Consequences + +- Facility composition is open to any conformant SBOLInventory graph rather than a closed product or station enum. +- Qualification belongs to each capability offering, not to an Asset or an adapter, and neither catalog data nor adapter availability promotes the other. +- Material binding uses exact `MaterialLot -> sbol:built -> Component` identity rather than display-name matching. +- Explicit movement nodes work across two or many Assets and do not assume that the mover is a human or a robot. +- EBEF is an acceptance facility, not a special compiler backend. Public equipment remains `Described` with `UnspecifiedControl`; explicitly synthetic twins establish simulation behavior without implying hardware access. +- General robotics, physics, scene, and remote-compute concerns remain outside this repository under [0042](0042-robotics-incubates-separately.md). diff --git a/docs/language/modules.md b/docs/language/modules.md index ed1fbfe..d0d477e 100644 --- a/docs/language/modules.md +++ b/docs/language/modules.md @@ -68,7 +68,9 @@ An idiomatic project separates reusable intent from the runnable composition: ```text lab.toml -targets/ +inventory/ + facility.ttl +adapters/ opentrons-ot2.toml src/ designs/ @@ -86,6 +88,7 @@ tests/ build_plasmid.lab .lab/ build/ + plan/ runs/ ``` @@ -93,10 +96,11 @@ tests/ - `policies` holds site- or project-specific scientific acceptance decisions. - `workflows` holds reusable durable orchestration. - `programs` wires designs, policies, parameters, and workflows into runnable entry points. -- `targets` holds site configuration: one file per bench a project compiles for. +- `inventory` holds portable SBOLInventory facility catalogs and material ledgers. +- `adapters` holds non-secret operational configuration bound to exact facility Assets by `lab.toml`. - `.lab/` is generated output and runtime state, never hand-authored source. -These names are conventions rather than keywords, except `targets`, which `lab build --target ` resolves by path. The module system should not give a directory magical semantics merely because it is called `workflows`. +These directory names are conventions rather than language keywords. `lab.toml` names the inventory and adapter-profile paths explicitly; the module system does not give a directory magical semantics merely because it is called `workflows`. A program's modules are lowered together, so an artifact declared in `designs` may be realized by a workflow in `workflows`, and either may come from a dependency package. @@ -112,23 +116,13 @@ default-member = "packages/golden-gate" `default-member` names the package a command acting on one package operates on, and is required once a workspace has more than one member. Generated artifacts and `lab.lock` live at the workspace root; each member keeps its own `src/`, dependencies, and version. -## Target profiles +## Facility inventory and adapter profiles -A target profile describes one bench. `lab build --target opentrons-ot2`, or a `[build] target = "opentrons-ot2"` in the manifest, reads `targets/opentrons-ot2.toml` and hands it to the backend the profile names: +An SBOLInventory document describes the laboratory that can realize an experiment: Facilities contain Zones, Zones locate Assets and MaterialLots, and Assets own qualified CapabilityOfferings. The facility phase shared by a configured `lab build` and `lab plan` allocates each reachable workflow requirement to an exact offering and Asset before any backend is invoked. -```toml -[target] -backend = "opentrons.ot2" -api_level = "2.21" - -[stages.plating.agar_plate] -labware = "nest_96_wellplate_100ul_pcr_full_skirt" -slots = ["5", "6"] -``` - -A profile's filename is its name, so the file does not state one and cannot disagree with the name a build resolved it by. Emitted plans carry that name, so an operator reading a protocol can see which bench it was compiled for. `backend` names the backend that consumes the profile, spelled the one way that backend spells itself; a profile written for another backend is rejected rather than compiled. +An adapter profile cannot choose a machine or backend. `lab.toml` binds one explicit driver and profile path to one exact Asset IRI, and the selected facility offering determines whether that binding participates in a plan. The profile contains non-secret implementation configuration that the driver needs but the portable catalog does not carry; endpoints and credentials remain local runtime inputs. -Every field has a default, so a profile states only what differs from the backend's reference bench. Unknown keys are rejected rather than ignored: a misspelled slot that silently fell back to a default is how a protocol ends up aspirating from the wrong place. +Every adapter-profile field has a checked default, and unknown keys are rejected rather than ignored. The `lab.adapter-profile.v2` contract contains no backend or Asset selector; the removed `[target]` table is invalid, and implementation-specific protocol settings such as the OT-2 API level live under `[protocol]`. The reviewed plan stages the canonical profile and records its SHA-256 beside the exact Asset and driver binding. Within a module, examples conventionally put providers before consumers: imports first, then shared data types, inventory values, biological declarations, and finally workflows. Dependency correctness still comes from resolved symbols and typed dataflow rather than textual order, filenames, or names such as “level 1” and “level 2.” @@ -144,20 +138,31 @@ edition = "2026" [build] entry = "src/programs/main.lab" -target = "opentrons-ot2" [inventory] -materials = ["BsaI", "T4_DNA_ligase", "pSB1C3"] -artifacts = ["composite_plasmid_1"] +document = "inventory/facility.ttl" +# Required only when the document contains more than one facility: +facility = "https://example.org/facilities/tet-lab" + +[[execution.adapters]] +asset = "https://example.org/facilities/tet-lab/star-1" +driver = "hamilton.star" +profile = "adapters/star-1.toml" [dependencies] parts = "1.2" local-policies = { path = "../policies" } ``` -`[inventory]` states what the laboratory already has: `materials` a reaction may draw on, and `artifacts` that are already realized and are not built again. Both name the symbolic identities `src/` declares, so a target build resolves artifact dependencies against the manifest rather than against a separate data file, and both default to empty. +`[inventory] document` names a package-relative SBOLInventory document in Turtle, RDF/XML, JSON-LD, or N-Triples. Lab validates the complete SBOL 3 and SBOLInventory Profile 0.2 graph before planning. If `facility` is omitted, the document must contain exactly one facility; otherwise the absolute Facility IRI selects one exactly. + +Each required source declaration reaches the graph through its exact `sbol_identity`, and availability means one active MaterialLot in the selected facility whose `sbol:built` points to that exact local Component. No declaration name, display ID, supplier identifier, or IRI prefix is used for matching. Zero lots leaves the dependency blocked, one freezes a Component-to-MaterialLot binding in `lab.dependency-build.v1`, and several produce an allocation ambiguity instead of a silent first choice. A built artifact with one active lot is reused through the same rule. + +The old `materials` and `artifacts` arrays remain as a mutually exclusive legacy form while existing examples migrate. They retain symbolic behavior and are identified as `legacy_symbols` in emitted dependency manifests; new packages should use `document`. + +Each `[[execution.adapters]]` entry explicitly binds one exact SBOLInventory Asset IRI to a stable Lab driver and a package-relative, non-secret adapter profile. Adapter bindings require an inventory document. They do not duplicate the Asset's manufacturer, model, location, capability offerings, qualification, or control mode, and Lab never infers a driver from those catalog facts. Runtime endpoints and credentials do not belong in this portable manifest or profile. `lab check` resolves every binding against the selected facility and requires at least one exact capability-kind and control-mode match. `lab build` freezes those joins and profile hashes in `adapter_bindings.json`; planning, simulation, and execution eligibility are derived separately from effective activity, the offering's stated qualification, and the adapter's actual services. -`[build] target` names the profile a plain `lab build` compiles for, so the command a laboratory runs every day produces the protocols its robots execute rather than intermediate IR. It names a profile under `targets/` and nothing else: a value carrying a path separator is rejected. `--target` compiles for a different bench and `--no-target` stops at portable module IR, so a package that declares a default keeps both. A package that declares no default builds module IR alone. +`lab build` always emits portable experiment artifacts. When a runnable package declares `inventory.document`, the same command also performs facility allocation and specialization under `.lab/build/`: it follows only capability instances reachable from the entry's `main`, binds them to exact offerings and Assets, and invokes only compatible adapters attached to those selected Assets. It does not require an adapter for semantic or manual planning, does not infer a driver, and does not silently resolve candidate or adapter ambiguity. `lab plan` runs this same facility phase separately under `.lab/plan/` when a reviewed plan is wanted without the portable module bundle. Source modules are discovered recursively beneath `src`. Their names are the normalized package name followed by their relative path, so `src/workflows/build-plasmid.lab` becomes `tet_reporter.workflows.build_plasmid`. diff --git a/docs/language/open-questions.md b/docs/language/open-questions.md index 8d4ab94..4339a69 100644 --- a/docs/language/open-questions.md +++ b/docs/language/open-questions.md @@ -26,35 +26,27 @@ A standard module written in Lab can declare roles, membership, data types, arti A catalogued item is declared with `buy` against an imported kind, states the fields of its type, and names its own type where its kind is generic — `buy promoter pTet: Promoter` — so the biological catalog is written in Lab. -What a kind *is* now travels with it: a role may name an ontology term and a kind plays roles, so `Plasmid` states that it is a nucleic acid and an engineered region ([`0039`](decisions/0039-roles-carry-ontology-terms.md)). A sequence can now be declared as a named `DNA` value and referenced from one or more designs ([`0043`](decisions/0043-sequences-are-first-class-design-values.md)). What remains open is the catalog record around that value: its provenance chain and version, whether its sequence was asserted or derived, and how a catalogued item's opaque `identity` distinguishes a resolvable registry record from a supplier's order number. It remains open how biological catalogs expose those richer declarations without reducing them to untyped properties or compiling changing catalog contents into `std`. The intended direction is recorded in [`sbol.md`](sbol.md). +What a kind *is* now travels with it: a role may name an ontology term and a kind plays roles, so `Plasmid` states that it is a nucleic acid and an engineered region ([`0039`](decisions/0039-roles-carry-ontology-terms.md)). A sequence can now be declared as a named `DNA` value and referenced from one or more designs ([`0043`](decisions/0043-sequences-are-first-class-design-values.md)). Exact identity is no longer ambiguous: `sbol_identity` names an SBOL Component and `supplier_identity` names a supplier order line. What remains open is the catalog record around that value: its provenance chain and version, whether its sequence was asserted or derived, and how biological catalogs expose those richer declarations without compiling changing catalog contents into `std`. The intended direction is recorded in [`sbol.md`](sbol.md). -## Target contracts +## Adapter lowering contracts -A kind now declares a schema, so the language states which properties an artifact -may hold and what each contains. What it still cannot state is which of them a -*target* consumes: the OT-2 backend reads `reaction_volume` and -`digest_temperature` by name, and a schema gives it something to validate against -without telling it what to expect. This is why moving `plasmid` into -`std.bio.designs` removes biology from the frontend and not from the toolchain. +A kind now declares a schema, so the language states which properties an artifact may hold and what each contains. What it still cannot state is which properties a capability-aware adapter consumes: the OT-2 implementation reads `reaction_volume` and `digest_temperature` by name, and a schema gives it something to validate against without telling it what to expect. This is why moving `plasmid` into `std.bio.designs` removes biology from the frontend and not from the toolchain. -Schema composition is also unresolved. A kind cannot extend or refine another, so -a target-specific chemistry schema has no way to say it adds to the design one. +Schema composition is also unresolved. A kind cannot extend or refine another, so an adapter-specific chemistry schema has no way to say that it adds constraints to the design schema. -## Property schemas and target contracts +Artifact properties are backend-neutral typed expressions, while the initial OT-2 specialization requires a documented property set. Packages still need reusable property schemas, defaults, refinements, and capability-lowering contracts. Such a contract should allow an adapter to state what it consumes without adding experiment-specific property names or diagnostics to the core checker. -Artifact properties are backend-neutral typed expressions, while the initial OT-2 specialization requires a documented property set. Packages still need a way to declare reusable property schemas, defaults, refinements, and target capability contracts. This should allow a target to state what it consumes without adding experiment-specific property names or diagnostics to the core checker. +Reaction chemistry is the sharpest case. A design states `reaction_volume: 20 uL`, and the facility-selected OT-2 adapter interprets it, but nothing in the language says which properties a Golden Gate assembly requires or what their units must be. The unit check lives in adapter lowering rather than a declared schema, so another adapter that wanted the same parameters would restate them. -Reaction chemistry is the sharpest case. A design states `reaction_volume: 20 uL`, and the OT-2 target interprets it, but nothing in the language says which properties a Golden Gate assembly requires or what their units must be. The unit check lives in the target's lowering rather than in a declared schema, so a target that wanted the same parameters would restate them. +## Facility configuration and allocation policy -## Target profiles and backend selection +Independent target profiles and backend selection have been removed from the package workflow. The open composition problem is now sharper: stable physical facts should be represented once in SBOLInventory, while private or runtime-only implementation configuration remains in the exact Asset-to-adapter overlay. The current liquid-handler adapters still accept detailed deck configuration that should move into typed Asset composition, positions, and offering parameters where the profile can represent it efficaciously. -A target profile configures one backend for one bench, and `lab build --target` resolves it by filename under `targets/`. The profile's `backend` field is validated but not dispatched on: there is one backend, and it is named concretely. A second backend needs a registry, a way for a profile to select among installed backends, and a rule for what a program may assume about a target it has not been compiled for. - -Profile composition is also unresolved. Sites that share most of a layout have no way to express one profile in terms of another, and nothing distinguishes a capability a bench has from a choice its operator made. +Allocation policy is also unresolved. Deterministic candidate ordering deliberately does not choose between equally eligible offerings, and sites need an explicit, reviewable way to express preferences, reservations, capacity sharing, and scheduling without putting those transient decisions into the persistent facility catalog. ## Inventory identity, availability, and provenance -Inventory constructors currently associate a typed source symbol with an external string. Stable identifiers, aliases, lots, quantities, locations, expiration, provenance, trust, and asynchronous availability are unresolved. A planner must distinguish “this design refers to an inventory identity” from “a suitable physical lot is available now.” +Design identity and physical availability are now separate. `sbol_identity` names an exact SBOL Component; facility planning loads a validated SBOLInventory document, restricts active MaterialLots to the selected facility, joins them through `sbol:built`, rejects ambiguity, and freezes the selected lot together with the facility and document hash. Quantity, expiration, containment, reservation, allocation policy beyond refusing ambiguity, trust policy, and asynchronous availability remain open. ## Package resolution diff --git a/docs/language/sbol.md b/docs/language/sbol.md index a784a70..c91d4f1 100644 --- a/docs/language/sbol.md +++ b/docs/language/sbol.md @@ -164,10 +164,7 @@ error: 'engineered region' is neither an IRI nor a compact identifier ## Layer 1: identity that resolves -Today `CheckedDeclaration::Catalog` carries `identity: String`, defaulting to -the declared name, and `source_lowering.rs` is its only consumer, reading it -into a `BTreeMap`. It is an opaque string: no scheme, no -namespace, no resolution, no version. +`CheckedDeclaration::Catalog` carries an optional exact `sbol_identity` separately from its `supplier_identity`, which defaults to the declared name. `CheckedDeclaration::Artifact` carries the same optional `sbol_identity`. The source lowerer uses only the supplier identifier for existing device-specific manifests; inventory resolution uses only the SBOL Component IRI. Two changes. @@ -188,23 +185,19 @@ that `sbol3::design::sanitize_display_id` gets wrong, keeping `pUC19-A` and `pUC19_A` distinct, and carries a test asserting the encoding still satisfies rule sbol3-10201. -**An identity distinguishes a registry record from an order line.** Both are -written the same way, as the property they already are: +**A design identity is separate from an order line.** The two meanings have distinct fields: ```lab buy: part J23101: - identity = "https://synbiohub.org/public/igem/BBa_J23101/1" + sbol_identity = "https://synbiohub.org/public/igem/BBa_J23101/1" restriction_enzyme BsaI: - identity = "NEB-R0535" + supplier_identity = "NEB-R0535" digest_temperature = 37 C ``` -An absolute IRI is resolvable and carries a design. A catalog number names -something to order. The compiler treats them differently because they mean -different things, which is the distinction -[0021](decisions/0021-typed-external-identities.md) collapsed. +`sbol_identity` is an absolute IRI naming the SBOL Component represented by either a `build` or `buy` declaration. `supplier_identity` is available only on `buy`, defaults to the declaration name, and names something to order. The legacy `identity` spelling remains an alias for `supplier_identity` during migration. The compiler carries both meanings separately, which restores the distinction [0021](decisions/0021-typed-external-identities.md) collapsed. Where an identity resolves, the local declaration is checkable against the registry record. A part declared `Promoter` whose SynBioHub record @@ -359,7 +352,7 @@ The correspondence with `provenance.rs` is exact: | `across 3 biological replicates` | three Implementations with distinct `wasGeneratedBy` | | `accept concentration >= 100 ng/uL` | `ExperimentalData` and an OM `Measure`, gathered in an `Experiment` | | the workflow that built it | `prov:Plan`, with `Association.hadRole = DBTL_BUILD` | -| the target profile and instrument | `prov:Agent` | +| the allocated Asset and its execution adapter | `prov:Agent` | The property that matters: **independence survives the round trip.** A third party reading Lab's output can recompute which samples are biological replicates @@ -620,69 +613,28 @@ catalogs expose sequences, provenance, and versions "without reducing them to untyped properties or compiling changing catalog contents into `std`". This is how. -### The execution boundary becomes PROV-O +### Facility catalogs and execution records have distinct owners -This is the layer the earlier design did not reach, and the correspondence is -field for field. +SBOLInventory supplies the persistent facility graph that core SBOL 3 deliberately does not define. Its extension classes and properties express the stable catalog and ledger facts: -```rust -pub struct WorkcellNode { pub id: String, pub after: Vec, pub action: WorkcellAction } -pub struct LedgerEntry { pub node: String, pub event: LedgerEvent, pub at_unix_seconds: u64 } -pub enum LedgerEvent { Started, Confirmed, Completed, Failed } +```text +a facility contains zones +zones locate assets and material lots +assets expose capability offerings +workflows require capabilities +plans bind requirements to offerings and assets +runs record material changes and evidence ``` -against +The first three statements and the persistent MaterialLot catalog belong to SBOLInventory. Workflow requirements belong to compiler IR. Allocation and scheduling belong to the Lab planner. The reviewed plan freezes every requirement-to-offering-to-Asset binding, exact MaterialLot binding, adapter profile, document digest, and dependency edge without translating the facility graph into a private TOML inventory model. -```rust -pub struct Activity { ..., pub started_at_time: Option, pub ended_at_time: Option, - pub was_informed_by: Vec, - pub qualified_usage: Vec, pub qualified_association: Vec } -pub struct Association { ..., pub agent: Option, pub had_role: Vec, pub had_plan: Option } -pub struct Usage { ..., pub entity: Option, pub had_role: Vec } -``` +The runtime then appends standard SBOL and PROV structure to a new inventory document. One completed plan becomes an `Activity`; exact Assets and input MaterialLots become qualified `Usage` entities with SBOLInventory roles; the reviewed plan, ledger, adapter profiles, and child documents become hashed `Attachment` evidence; and each live output MaterialLot is an `Implementation` carrying `prov:wasGeneratedBy` and exact lineage. The source inventory is never modified in place. + +The reviewed execution DAG remains a versioned Lab document rather than pretending that RDF is an ordered dispatch language. `Execute`, `MoveMaterial`, and `Manual` nodes need explicit dependencies, replay rules, confirmations, and a durable event ledger. The runtime is keyed by the reviewed plan digest and never re-plans from the graph. + +This boundary also preserves claim strength. A catalog offering may be `Described`, `Plannable`, `Simulatable`, `Executable`, or `Qualified`, and the planner may bind it only when the workflow's minimum qualification is met. An adapter declaration does not promote catalog qualification, and catalog product metadata never selects a driver. Simulation and live execution use incompatible ledger state, and only live execution may mint physical output MaterialLots. -`after` is `wasInformedBy`. `Started` and `Completed` are `startedAtTime` and -`endedAtTime`. `Confirmed` is an `Association` naming the operator as an -`Agent`. A `WorkcellStation { name, kind }` is an `Agent`. The emitted plan is a -`Plan`. The ledger's own comment already describes what it is: "the run's memory -and its evidence". - -Today the design document and the run record are two files with no identity in -common. Keyed by the same IRIs, the chain from a design through the run that -executed it to the physical tube it produced is one graph. That is also where -`accept` claims finally put their runtime evidence, which `support.md` lists as -unresolved, and it is the point at which the emitted document stops being a -protocol and becomes a laboratory notebook. - -The shape maps; the resolution does not, and four gaps are worth knowing before -committing to this as the record format rather than an export of it. - -`Activity` carries one `started_at_time` and one `ended_at_time`, and neither -`Association` nor `Usage` has a time field. The ledger timestamps every event, -so a step-level timeline has nowhere standard to go; it fits only as one -Activity per node, or as extension triples. - -There is no `prov:generated` forward edge. Outputs are discoverable only by -scanning for objects whose `wasGeneratedBy` names the activity, and -`Document::resolve` is a linear scan, so this is quadratic on a large document -without an index of your own. - -`Agent` and `Plan` have no fields beyond the shared Identified and TopLevel -data. An instrument, an operator, and a piece of software are distinguishable -only by name, description, or extension predicates. There is also no delegation, -so "operator supervised instrument" is not expressible, and no attestation -concept at all, so `Confirmed` maps to a custom `hadRole` IRI and nothing -validates it. - -`hadRole` is checked against a closed four-value vocabulary, design, build, -test, and learn. Custom lab roles are neither accepted nor rejected, they are -skipped. And using the standard roles has teeth: a `test` entity is required to -be `ExperimentalData` and a `build` entity an `Implementation`. - -None of this blocks the work. It does mean the run record is Lab's format -carrying PROV-O structure, rather than PROV-O being the format, and the -extension triples that make up the difference should be designed deliberately -rather than accumulated. +The resulting chain is one identity-preserving graph: a design Component is realized by an exact MaterialLot, consumed by an Activity through an exact Asset offering, and related to any generated MaterialLots and evidence. Lab keeps compiler and dispatch state private while emitting the durable laboratory record through the SBOLInventory profile. ### Where it should not go @@ -958,20 +910,7 @@ things, and guessing wrong produces a parse error that blames the document rather than the guess. An SBOL document names its serialization in its extension or it is not discovered. -**Moving designs into SBOL changes what an order names**, and the compiler said -so before anything was built: - -``` -error: the manifest declares material 'B0015', which this build never uses; - a catalogued name that was renamed leaves its old identity here -``` - -A Lab `buy part B0015` defaults its identity to the symbol. The same part read -from SBOL carries the registry IRI it resolves to, and that IRI is what an order -names. The manifest's `[inventory]` list had to follow. That diagnostic is the -mitigation [0021](decisions/0021-typed-external-identities.md) added after a -rename silently turned "use stock" into "build it", and it caught a real -identity change on its first encounter with a second language. +**Moving designs into SBOL does not change what an order names.** An imported Component carries its registry IRI as `sbol_identity`; a bought declaration separately carries `supplier_identity`, defaulting to its Lab symbol when no order identifier is stated. Inventory-backed planning follows only `sbol_identity -> sbol:built -> MaterialLot`, while device manifests may still use the supplier identifier. The symbolic `[inventory] materials` array remains a legacy migration form and no longer defines the semantic model. ### Widening the mapping found three real modelling gaps @@ -1245,11 +1184,7 @@ its omissions report intact. It is a projection of the output, not a peer of it. ## Open questions and risks -**`sbol3` has no serde.** `CheckedModule` is serde-serialized under -`lab.portable-module.v4`, so SBOL objects cannot ride inside the portable module -IR. Keeping IRIs as strings in `CheckedModule` and rebuilding SBOL objects at -emission is probably right, since it keeps the portable IR self-describing, but -it needs deciding rather than discovering. +**Portable SBOL identities are strings by design.** `CheckedModule` is serde-serialized under `lab.portable-module.v8`, so pySBOL3 and sbol-rs objects do not ride inside portable compiler IR. `sbol_identity` carries the exact absolute Component IRI as a string, while typed SBOL objects remain behind the authoring and inventory boundaries. **No OM unit constants in sbol-rs.** Lab has `Quantity
    `, `Quantity`, and `Quantity`, and emitting them as OM `Measure` values needs unit IRIs that @@ -1299,13 +1234,7 @@ and the RDF I/O stack are not obviously fine. This is why the validation pass runs from `lab-project` rather than from `compile_parsed_module`, and it needs measuring rather than assuming. -**Identity migration is broad, and it breaks two wire formats.** -`PORTABLE_MODULE_SCHEMA_VERSION` moved to `lab.portable-module.v4` when grounding -landed; `DependencyBuildManifest` serializes artifact names into an on-disk manifest -with its own `schema_version`. Both are deliberate, versioned boundaries, so the -break is manageable, but it should be one break rather than several. Land the -identity type and the minting rules first, then move consumers, rather than -letting IRIs leak outward one pass at a time. +**Identity migration crosses versioned boundaries.** `PORTABLE_MODULE_SCHEMA_VERSION` moved to `lab.portable-module.v4` when grounding landed, to `lab.portable-module.v5` when SBOL Component and supplier identities became separate fields, to `lab.portable-module.v6` when action capability names became absolute SBOLInventory capability-kind IRIs, to `lab.portable-module.v7` when durable workflow calls began preserving exact resolved callee identities for package-wide reachability, and to `lab.portable-module.v8` when action parameters began preserving absolute SBOLInventory property-kind IRIs. The dependency manifest independently moved to `lab.dependency-build.v1` when it began recording inventory source provenance and exact Component-to-MaterialLot bindings. The checker's tables are the bulk of the mechanical work: fifteen `HashMap` and `BTreeSet` fields on `SemanticContext`, plus diff --git a/docs/language/semantics.md b/docs/language/semantics.md index bca5235..e89fa1b 100644 --- a/docs/language/semantics.md +++ b/docs/language/semantics.md @@ -71,15 +71,17 @@ it. Provenance is discarded after the type system has done its work, not before. ## Declarations, properties, and identities -A biological declaration is immutable intent. Its `name = value` entries are typed declarative properties: deterministic expressions evaluated once, never mutations and never durable effects. Portable module IR preserves them as named checked expressions without assigning target-specific meaning to every property name. +A biological declaration is immutable intent. Its `name = value` entries are typed declarative properties: deterministic expressions evaluated once, never mutations and never durable effects. Portable module IR preserves them as named checked expressions without assigning method- or adapter-specific meaning to every property name. Inside a declaration body the two operators divide cleanly. `=` associates a name with a value; `:` gives a name a type. That is what lets a declaration's shape be read before its meaning is resolved, because one token after the name decides which is which without knowing the word that opened the block. -A source value may stand for an external identity. `buy part J23101` declares a source symbol of type `Part` whose identity — what a supplier's order names — defaults to the declared name and is written explicitly only where the two differ. The symbol name and external identifier are distinct: renaming one does not silently rewrite the other. The typed symbol can appear in properties and expressions; using a bare string where a `Part`, `Backbone`, `Chassis`, or `Antibiotic` is required is a type error. +A declaration may carry an exact biological-design identity independently of how that design is obtained. `sbol_identity` is an absolute IRI naming the SBOL Component represented by either a `build` or `buy` declaration. A bought declaration may additionally state `supplier_identity`, the order identifier used to acquire it; that identifier defaults to the declared name. The source symbol, SBOL identity, and supplier identity are distinct, so renaming one does not silently rewrite either of the others. The typed symbol can appear in properties and expressions; using a bare string where a `Part`, `Backbone`, `Chassis`, or `Antibiotic` is required is a type error. `Chassis` and `Strain` are different kinds of thing. A chassis is a catalogued host organism, declared with `buy`. A strain is a declared artifact: a chassis together with the plasmid designs it carries. One chassis appears in many strains, and one plasmid design may appear in strains built on different chassis. -Typed identity is not availability. It does not establish a lot, quantity, location, provenance chain, or fitness for use. Those claims require inventory resolution and runtime evidence. +Design identity is not availability. An `sbol_identity` does not establish a lot, quantity, location, provenance chain, or fitness for use. Those claims require exact MaterialLot resolution against a validated SBOLInventory document and runtime evidence. + +During inventory-backed planning, a checked `sbol_identity` is joined only to active MaterialLots in the selected facility whose `sbol:built` names that exact Component IRI. A unique candidate is frozen in the dependency plan together with the facility IRI and source-document hash. No candidate is a missing input; several candidates are an allocation ambiguity requiring policy or review. The compiler never treats candidate ordering as allocation. ## Commands and events @@ -87,7 +89,7 @@ An effect binding records a command and durably waits for the corresponding even Workflow replay must not repeat completed physical actions. Time, randomness, inventory queries, device interaction, network access, and human decisions are effects rather than ambient language operations. -Every resolved action contract names the capability required to dispatch it, the type of each operand and result, and how each operand participates in physical ownership. `copy` is for freely reusable information, `borrow` permits observation without consuming a material, and `take` transfers a material into the action. +Every resolved action contract names the capability required to dispatch it as an absolute SBOLInventory capability-kind IRI, the type of each operand and result, and how each operand participates in physical ownership. `copy` is for freely reusable information, `borrow` permits observation without consuming a material, and `take` transfers a material into the action. Capability matching is exact IRI equality; source actions that describe composite biological work retain an explicit refinement boundary rather than pretending to name one instrument operation. The complete standard-action audit is in [`capabilities.md`](capabilities.md). `=` and `<-` therefore have different replay laws. `=` evaluates a deterministic expression or commits an explicit state transition. `<-` creates a durable command boundary and obtains its value from a recorded completion event. The result may look like a local binding, but the physical action must not be repeated merely because a workflow is replayed. @@ -107,9 +109,9 @@ Heterogeneous reusable design values may acquire a union element type. A compone ## Chemistry and site configuration -A declaration's quantity-valued properties state reaction chemistry: reagent volumes, cycle counts, and thermal holds. These are claims about the science, so they belong to the artifact and travel with it into every target. +A declaration's quantity-valued properties state reaction chemistry: reagent volumes, cycle counts, and thermal holds. These are claims about the science, so they belong to the artifact and travel with it into every facility plan. -Which labware sits in which deck slot, which pipette is on which mount, and how many plates a bench holds are claims about a laboratory. A target specialization reads them from its own configuration, not from source. The same program compiled against two benches produces two different robot plans and one unchanged set of designs. +Which labware sits in which deck slot, which pipette is on which mount, and how many plates a bench holds are claims about a facility Asset. Stable physical facts belong in SBOLInventory where the profile can express them; private or runtime-only implementation configuration belongs in the exact Asset-to-adapter overlay. The same program planned against two compatible facilities can produce two different device plans from one unchanged set of designs and requirements. ## Acceptance @@ -121,11 +123,11 @@ The compiler may establish that a workflow can produce the kinds of evidence req Only the third judgment produces an accepted physical material. -## Portable semantics and target specialization +## Portable semantics and facility specialization -Portable module checking resolves module-provided contracts, types expressions, verifies workflow returns, and checks affine material ownership. It does not choose a robot, a deck, a reaction chemistry, or a laboratory schedule. +Portable module checking resolves module-provided contracts, types expressions, verifies workflow returns, and checks affine material ownership. It does not choose a facility, Asset, deck, or laboratory schedule. -A target specialization may interpret a documented set of checked properties and resolved operations. It must fail explicitly when required properties, capabilities, value shapes, capacities, or operation sequences are unsupported. Target diagnostics should describe generic constraints where possible; experiment names and tutorial-specific sequences do not belong in the core language checker. +Method selection may interpret a documented set of checked scientific properties and resolved operations while remaining facility-independent. Facility planning then binds the resulting requirements to exact capability offerings and Assets, and only the adapter bound to that Asset may interpret implementation-specific configuration. Each boundary must fail explicitly when required properties, capabilities, value shapes, capacities, or operation sequences are unsupported. Diagnostics should describe generic constraints where possible; experiment names and tutorial-specific sequences do not belong in the core language checker. ## Reactive execution diff --git a/docs/language/support.md b/docs/language/support.md index 0712fee..434763d 100644 --- a/docs/language/support.md +++ b/docs/language/support.md @@ -1,21 +1,21 @@ # Language support -Support is tracked by compiler phase. `Lower` means verified portable module IR unless a row explicitly names a target. `Execute` distinguishes generated or legacy executable artifacts from the still-missing durable workflow runtime. +Support is tracked by compiler phase. `Lower` means verified portable module IR unless a row explicitly names facility specialization. `Execute` distinguishes generated device artifacts from the durable reviewed-plan runtime. | Feature | Parse | Resolve | Type | Lower | Execute | | --- | --- | --- | --- | --- | --- | | Package-declared artifact kinds (`artifact Type:`) | yes | imported kinds | schema fields and `declares` | `CheckedDeclaration::ArtifactKind` and the interface schema | n/a | -| Artifact instances naming a package's word | yes | resolved against imported kinds | properties against the schema | portable module | specialized targets | +| Artifact instances naming a package's word | yes | resolved against imported kinds | properties against the schema | portable module | facility-selected adapters | | `declares` completeness rules | yes | property names only | presence, not values | `CheckedPresence` | n/a | | Optional schema fields (`name?:`) | yes | yes | required unless marked | `CheckedSchemaField.optional` | n/a | | Bought-item properties (`buy`) | yes | yes | against the kind's schema, strictly | `CheckedDeclaration::Catalog.properties` | n/a | | Quantity types (`Quantity
      `) | yes | yes | unit-exact | `CheckedType::Quantity` | n/a | | `across N biological replicates` | yes | yes | count resolved, and evidence checked against the lineage it spans | `CheckedAcceptance.replicates` | n/a | | Material lineage and replicate class | n/a | n/a | derived from action results | `provenance::lineage` | n/a | -| Provenance verbs (`build`, `buy`) | yes | yes | `require`/`accept` only on what is built; identity only on what is bought | `CheckedDeclaration::Artifact` and `Catalog` | manifest cross-check at build | +| Provenance verbs (`build`, `buy`) | yes | yes | `require`/`accept` only on what is built; supplier identity only on what is bought; SBOL identity on either | `CheckedDeclaration::Artifact` and `Catalog` | manifest cross-check at build | | Schemas contributed to by several modules | yes | yes | union of every kind declaration in scope | the merged interface schema | n/a | | Reagent-owned chemistry with design override | yes | yes | a stated value wins over the item's | `CheckedDeclaration::Catalog.properties` | read by the lowerer | -| Declarative artifact properties with `=` | yes | expressions | inferred checked values | `CheckedProperty` | target-dependent | +| Declarative artifact properties with `=` | yes | expressions | inferred checked values | `CheckedProperty` | adapter-dependent | | Quantity-valued chemistry properties | yes | yes | unit-checked at lowering | chemistry dictionaries | generated protocols | | Mandatory workflow `(inputs) -> T` or `-> (name: T, ...)` signature | yes | yes | inputs, result arity, names, and types | yes | runtime pending | | Quantity literals | any expression position | built-in units | dimension subset | yes | yes | @@ -25,18 +25,18 @@ Support is tracked by compiler phase. `Lower` means verified portable module IR | Bundled `std` module imports | yes | eight modules | module values and contracts | yes | no runtime dispatch | | Bundled `std` modules written in Lab | yes | `designs`, `golden_gate`, `parts`, `backbones`, `reporters` | compiled once at startup | resolved through `ModuleInterface` | n/a | | Optional trailing action clauses | yes | contract-driven | omitted operand binds to the empty list | yes | n/a | -| Typed external identities (`buy`) | yes | against imported kinds | nominal values | `CheckedDeclaration::Catalog` | no live inventory lookup | -| Heterogeneous list union inference | yes | symbols | e.g. `List` | yes | target-dependent | +| Typed external identities (`buy`) | yes | against imported kinds | nominal values | separate SBOL and supplier identity fields | exact packaged MaterialLot lookup during facility planning | +| Heterogeneous list union inference | yes | symbols | e.g. `List` | yes | adapter-dependent | | Project/package import graph | yes | module paths | imported module interfaces | yes | no | | `lab.toml` manifest and source discovery | n/a | yes | n/a | yes | no | | `[workspace]` members and default member | n/a | member packages | n/a | shared `.lab/build/` and `lab.lock` | no | | Path dependency resolution and lockfile | n/a | recursive path packages | imported module interfaces | `.lab/build/` index plus `lab.lock` | no | | Multi-module program lowering | n/a | whole program | n/a | one Design/Workflow module | n/a | -| `targets/*.toml` site profiles | n/a | n/a | n/a | validated deck, labware, instruments | `lab build --target` or `[build] target` | +| SBOLInventory plus exact Asset adapter bindings | n/a | Facility, Zone, Asset, Offering, and MaterialLot IRIs | profile and offering compatibility | allocation plus reviewed adapter bundles | facility-configured `lab build`, `lab plan`, and reviewed-plan preflight | | Registry dependency acquisition | n/a | rejected | no | no | no | | `role` declarations and `is` membership | yes | yes | bounds satisfied by role membership | `CheckedDeclaration::Role`, roles on type exports | n/a | | Roles crossing a module boundary | n/a | `ExportKind::Role` | membership restored from the interface | yes | n/a | -| Ontology grounding | `role X = "SO:0000167"`, `artifact P is X` | role terms and kind membership | term shape checked where written | `Grounding` resolves a type to its terms | not yet read by a target | +| Ontology grounding | `role X = "SO:0000167"`, `artifact P is X` | role terms and kind membership | term shape checked where written | `Grounding` resolves a type to its terms | not yet read by adapters | | Designs read from SBOL | an SBOL document in place of `.lab` designs | catalogued declarations built and then checked | the same rules a written design meets | `lab-sbol` reads components, sequences, and ordered parts | file discovery pending | | Circuit declarations and applications | yes | yes | yes | yes | no | | Callable circuit signatures with `-> T` | yes | yes | yes | yes | no | @@ -46,37 +46,38 @@ Support is tracked by compiler phase. `Lower` means verified portable module IR | Forgotten type arguments (`any Role`) | type-argument position only | yes | packing only where an annotation asks | `CheckedType::Any` | n/a | | Diagnostics with secondary spans and help | n/a | n/a | n/a | `Diagnostic.related` and `.help` | rendered by `lab check` on one file, and by the language server | | Top-level pure bindings | yes | yes | yes | yes | no | -| Named DNA values referenced by designs | `name: DNA = dna("...")` | references resolve across modules | DNA-typed design property | one reusable `design.dna_sequence` SSA value | target-dependent | +| Named DNA values referenced by designs | `name: DNA = dna("...")` | references resolve across modules | DNA-typed design property | one reusable `design.dna_sequence` SSA value | adapter-dependent | | `record` plus role membership (`is Event`, `is Evidential`) | yes | yes | yes | yes | no | -| Biological catalog identity, version, and provenance chains | syntax pending | no | no | no | no | +| Exact SBOL Component and supplier identities | `sbol_identity`, `supplier_identity` | declarations resolve normally | SBOL identity must be an absolute IRI | separate fields in `lab.portable-module.v8` | unique active MaterialLot frozen in `lab.dependency-build.v1` | +| Biological catalog version and provenance chains | syntax pending | no | no | no | no | | Tagged `record` declarations with `case` constructors | yes | yes | yes | yes | no | | Workflow declarations and calls | yes | yes | yes | yes | runtime pending | | Pure workflow bindings | yes | yes | yes | yes | no | | Explicit durable workflow `state` | yes | yes | yes | yes | no | | Built-in durable operations with `<-` | yes | yes | yes | yes | no | | Structured typed expression IR | n/a | yes | yes | yes | no | -| Action capability and ownership contracts | n/a | built-ins | built-ins | yes | no | +| Action capability and ownership contracts | n/a | built-ins | absolute SBOLInventory capability and property-kind IRIs, canonical parameter unit IRIs, ownership modes, and exact workflow callee identities | `lab.portable-module.v8`, `lab.capability-requirements.v2` templates, and reachable `lab.capability-requirement-instances.v2` | facility allocation pending | | Direct `return value, ...` and result checking | yes | yes | arity and per-result type | named result fields | no | | `match` / `case` with continuing-branch bindings | yes | yes | yes | yes | no | | `if` / `else` and `for` / `in` | yes | yes | yes | yes | no | | `when every` / `when after` | yes | yes | yes | yes | no | | Event emission | yes | yes | yes | yes | no | | Affine material-flow checking in portable workflows | n/a | action ownership modes | yes | yes | no | -| Dependencies from `Material` dataflow | yes | resolved `realize` and `transform` operands | yes | initial OT-2 target | generated plans and bundles | -| OT-2 properties and operation sequence | yes | checked properties and actions | target validation | automation IR | generated protocols only | +| Dependencies from `Material` dataflow | yes | resolved `realize` and `transform` operands | yes | portable graph plus exact MaterialLot bindings | reviewed facility plans and adapter bundles | +| OT-2 properties and operation sequence | yes | checked properties and actions | adapter validation | automation IR | generated protocols only | | Multi-plate allocation across declared slots | n/a | n/a | n/a | plate-and-well addresses | generated protocols | -| Human instruction package | n/a | n/a | target-validated | Markdown plus manifest | operator review required | +| Human instruction package | n/a | n/a | adapter-validated | Typst/PDF plus manifest | operator review required | | Durable workflow runtime | no | no | no | no | no | All complete source modules use the portable-module boundary. A backend may reject checked properties or operations it cannot preserve, but it cannot select a narrower source frontend. -`lab` resolves workspace members, same-package modules, and recursive path dependencies into one deterministic compilation order, detects package and module cycles, and checks an optional semver requirement against each path dependency's manifest. Each package compiles against the checked `ModuleInterface` values of its dependencies, so imported public symbols resolve and type-check across package boundaries. A package that declares a build entry must declare `workflow main` in that module; one that declares no entry is a library and is accepted without it. `lab build` writes portable module IR plus a package index under `.lab/build/` and a `lab.lock` recording each package's name, version, source, and dependency aliases. Registry dependencies fail closed: acquisition, integrity, caching, and visibility rules are unimplemented, and a manifest that declares one is rejected rather than silently ignored. +`lab` resolves workspace members, same-package modules, and recursive path dependencies into one deterministic compilation order, detects package and module cycles, and checks an optional semver requirement against each path dependency's manifest. Each package compiles against the checked `ModuleInterface` values of its dependencies, so imported public symbols resolve and type-check across package boundaries. A package that declares a build entry must declare `workflow main` in that module; one that declares no entry is a library and is accepted without it. `lab build` writes portable module IR, typed capability-requirement templates, and a package index under `.lab/build/`, plus a `lab.lock` recording each package's name, version, source, and dependency aliases. When the runnable package selects a facility, the build also writes the exact allocation, reviewed execution plan, adapter bundles, automation protocols, and operator PDFs under that same directory. Registry dependencies fail closed: acquisition, integrity, caching, and visibility rules are unimplemented, and a manifest that declares one is rejected rather than silently ignored. -The separate OT-2 specialization accepts plasmid properties (`backbone`, ordered `components`, `restriction_enzyme`, replicate counts, and reaction chemistry) and strain properties (`chassis`, carried `plasmids`, `selection`, replicate counts, and transformation chemistry) as checked symbol references, plus workflows composed from bundled standard-library effects. `buy` declarations give external inventory identities typed source names; strings are not used as component references. The source selects `realize`, provision, transformation, recovery, dilution, and plating operations. Dependencies are typed material inputs to `realize` and `transform`; the generic language does not encode assembly levels. The specialization emits a deterministic Lab manifest, human instructions, and OT-2 scripts, and explicitly rejects properties or operation sequences it cannot lower. +The separate OT-2 specialization accepts plasmid properties (`backbone`, ordered `components`, `restriction_enzyme`, replicate counts, and reaction chemistry) and strain properties (`chassis`, carried `plasmids`, `selection`, replicate counts, and transformation chemistry) as checked symbol references, plus workflows composed from bundled standard-library effects. `sbol_identity` gives built and bought declarations exact biological-design identities while `supplier_identity` remains order metadata. The source selects `realize`, provision, transformation, recovery, dilution, and plating operations. Dependencies are typed material inputs to `realize` and `transform`; the generic language does not encode assembly levels. The specialization emits a deterministic Lab manifest, human instructions, and OT-2 scripts, and explicitly rejects properties or operation sequences it cannot lower. -Deck layout, labware, instruments, and per-stage capacity come from a target profile rather than from constants, and allocation spills across every plate a profile declares. The target validates reaction balance against each design's own stated volume, replicate and dilution bounds, plate capacity, source-rack capacity, and tip capacity. A batch emits a robot protocol only for the stages its artifacts reach, and artifacts sharing a planning wave share one run. +Deck layout, labware, instruments, and per-stage capacity currently enter a liquid-handler adapter through its operational profile rather than backend constants, and allocation spills across every plate that checked configuration declares. Facility allocation selects the exact Asset and adapter before this specialization runs. The adapter validates reaction balance against each design's own stated volume, replicate and dilution bounds, plate capacity, source-rack capacity, and tip capacity. A batch emits a robot protocol only for the stages its artifacts reach, and artifacts sharing a planning wave share one run. -It does not yet read the ontology terms a kind is grounded in, nor resolve SBOL, inventory lots, overhang compatibility, sequence redesign, concentration normalization, inter-wave DNA preparation, or runtime acceptance evidence. Generated instructions and scripts require laboratory review and qualification before physical execution. The complete specialization boundary is documented separately in [`../integrations/opentrons-build.md`](../integrations/opentrons-build.md). +It does not yet read the ontology terms a kind is grounded in, allocate among several eligible inventory lots, reason over quantity or expiration, design compatible overhangs, redesign sequences, normalize source concentrations, prepare DNA between dependent waves, or attach runtime acceptance evidence. Generated instructions and scripts require laboratory review and qualification before physical execution. The complete specialization boundary is documented separately in [`../integrations/opentrons-build.md`](../integrations/opentrons-build.md). ## Editor support diff --git a/docs/language/syntax.md b/docs/language/syntax.md index 291d4fb..b47a67d 100644 --- a/docs/language/syntax.md +++ b/docs/language/syntax.md @@ -367,7 +367,7 @@ record PlateObservation: colonies: ColonyMap ``` -Duplicate property names are rejected. Portable checked IR preserves the property name and typed value; a target may consume a documented subset without the core AST growing one field per backend. +Duplicate property names are rejected. Portable checked IR preserves the property name and typed value; method selection or an allocated adapter may consume a documented subset without the core AST growing one field per implementation. ## Plasmid requirements and acceptance @@ -386,9 +386,9 @@ plasmid p_sensor: `require` is checked before physical construction. `accept` describes a runtime claim that must be supported by evidence. -## Typed inventory identities and target properties +## Typed design identities and specialization properties -Inventory identities enter through `buy` declarations against imported kinds, and plasmid properties refer to those symbols. Properties are backend-neutral typed expressions—not executable bindings and not evidence that inventory is physically available: +Biological designs enter through `build` and `buy` declarations against imported kinds, and plasmid properties refer to those typed symbols. An exact SBOL Component IRI may be attached to either provenance. These declarations and properties are not executable bindings or evidence that a material lot is physically available: ```lab use std.lab.plasmid @@ -422,9 +422,9 @@ strain reporter_host: serial_dilutions = 2 ``` -A bought item's external identity is what a supplier's order names. It defaults to the declared name and is stated as an `identity` property only where the two differ, so renaming a source symbol and changing an external identifier are distinct operations. Source symbols are values regardless of capitalization: `J23101`, `BsaI`, and `DH5alpha` do not become types because their names begin with capitals. +An `sbol_identity` is an absolute IRI naming the SBOL Component represented by a built or bought declaration. A bought item's `supplier_identity` names what a supplier's order line calls it and defaults to the declared name; `identity` remains a legacy alias for `supplier_identity`. The source symbol, design identity, and supplier identity are distinct, so renaming one does not silently rewrite the others. Source symbols are values regardless of capitalization: `J23101`, `BsaI`, and `DH5alpha` do not become types because their names begin with capitals. -The OT-2 specialization interprets these properties after ordinary module checking. Another target may ignore them, interpret other metadata, or reject the module. Target-specific property names are not encoded in the core checker. +The current plasmid-build method interprets the scientific properties after ordinary module checking, and the OT-2 adapter interprets its documented operational subset only after facility allocation. Another method or compatible adapter may interpret other metadata or reject the module. Method- and adapter-specific property names are not encoded in the core checker. The component list above has type `List`. A list that refers to both a dependent plasmid and ordinary parts has the inferred type `List`: @@ -434,7 +434,7 @@ components: [promoter_carrier, B0034, GFP, B0015] The union preserves the nominal alternatives; it does not convert the symbols to strings or a universal metadata value. -Multiple property-bearing artifacts and their realization workflows may be compiled by a compatible target. Replicate and dilution settings are currently interpreted by the initial OT-2 specialization, not by the core language. +Multiple property-bearing artifacts and their realization workflows may be compiled by a supported method and lowered through a compatible facility adapter. Replicate and dilution settings are currently interpreted by the initial plasmid-build method and OT-2 adapter, not by the core language. ## Plasmids and strains @@ -469,7 +469,7 @@ plasmid p_gfp: ligate_duration = 5 min ``` -Units are checked rather than assumed: `20 mL` where microlitres are expected is a diagnostic, not a thousandfold error on the bench. Water makes each reaction up to its stated volume, and reagents that over-subscribe that volume are rejected before any target sees the design. +Units are checked rather than assumed: `20 mL` where microlitres are expected is a diagnostic, not a thousandfold error on the bench. Water makes each reaction up to its stated volume, and reagents that over-subscribe that volume are rejected before facility allocation or adapter lowering. ## Evidence a claim is believed on diff --git a/examples/ebef/README.md b/examples/ebef/README.md new file mode 100644 index 0000000..9c8c33e --- /dev/null +++ b/examples/ebef/README.md @@ -0,0 +1,142 @@ +# EBEF reference facility + +This package exercises Lab's SBOLInventory ingestion against a public-data model of Caltech's [Resnick Ecology and Biosphere Engineering Facility](https://resnick.caltech.edu/resource-centers/ecology-and-biosphere-engineering-facility-ebef). + +## What EBEF is + +The EBEF is a shared wet-lab resource in Caltech's Resnick Sustainability Center for studying life across spatial scales. Caltech describes support for isolating, cultivating, and genetically manipulating diverse microorganisms; plant cultivation; fluorescence in situ hybridization and microscopy; protein expression and purification; and anaerobic techniques. The facility has dedicated microbiology and microscopy space in the basement and plant-cultivation space on the first floor. Its laboratory spaces are designed for BSL2+ containment. + +The public equipment page is the factual source for this example. It describes the kinds of spaces and equipment present, but it is not an asset registry or control contract. The checked-in graph records the page as `prov:wasDerivedFrom` and deliberately omits facts that would require access to EBEF's internal records. + +## What the reference catalog contains + +The catalog contains one `fac:Facility`, 12 `fac:Zone` objects, 28 `fac:Asset` objects, and 30 owned `fac:CapabilityOffering` objects. The following tree shows the modeled containment and composition. A chamber or growth cabinet is located in a room as an Asset and establishes a separate controlled Zone; instruments inside that environment are located in the established Zone. + +```text +Resnick Ecology and Biosphere Engineering Facility [Facility] +├── Main lab, basement [Room] +│ ├── Microbiology lab [WorkArea] +│ │ ├── Anaerobic chamber 1 [EnvironmentController] +│ │ │ └── Chamber 1 interior [ContainmentZone] +│ │ │ ├── Hamilton Microlab Prep +│ │ │ └── 96-well potentiostat +│ │ ├── Anaerobic chamber 2 [EnvironmentController] +│ │ │ └── Chamber 2 interior [ContainmentZone] +│ │ │ └── Swinging-bucket centrifuge +│ │ ├── Three Eppendorf S44i shaking incubators +│ │ ├── Grouped static incubators +│ │ ├── Agilent BioTek Epoch 2 plate reader +│ │ ├── ProFlex thermocycler +│ │ │ ├── Independently runnable block 1 [FunctionalUnit] +│ │ │ ├── Independently runnable block 2 [FunctionalUnit] +│ │ │ └── Independently runnable block 3 [FunctionalUnit] +│ │ ├── Azure 300 gel imager +│ │ ├── DNA and protein electrophoresis station +│ │ ├── Six-foot biosafety cabinet +│ │ └── AMSCO 630LS autoclave +│ ├── Microscopy lab [WorkArea] +│ │ ├── Dragonfly spinning-disk confocal microscope +│ │ └── Plasma cleaner +│ ├── Media preparation room [WorkArea] +│ │ └── Media and buffer preparation station +│ └── Freezer room [StorageZone] +│ └── Grouped 4 C, -20 C, and -70 C storage +└── Plant lab, first floor [Room] + ├── Two Conviron Gen1000 chambers, one Gen2000, and one GR48 + │ └── Each chamber establishes its own [EnvironmentZone] + ├── Four-foot biosafety cabinet + └── Soil and plant-waste autoclave +``` + +The corresponding capability surface is summarized below. Names such as `cap:LiquidHandling` abbreviate IRIs in the `https://sbol.io/ns/capability#` namespace. + +| Modeled area | Assets represented | Capability offerings represented | +| --- | --- | --- | +| Basement microbiology | Anaerobic chambers and their internal instruments, three shaking incubators, grouped static incubators, Epoch 2, ProFlex and three child blocks, gel imaging and electrophoresis equipment, biosafety cabinet, and autoclave | `AnaerobicEnvironmentControl`, `LiquidHandling`, `ElectrochemicalMeasurement`, `Centrifugation`, `ShakingIncubation`, `StaticIncubation`, `AbsorbanceMeasurement`, `Incubation`, `ThermalCycling`, `GelImaging`, `Electrophoresis`, `BiosafetyContainment`, `SteamSterilization` | +| Basement microscopy | Dragonfly confocal microscope and plasma cleaner | `ConfocalMicroscopy`, `PlasmaCleaning` | +| Media preparation | One workstation representing the publicly described balances, pH meter, MilliQ water, and preparation hood | `MediaPreparation`, `PhMeasurement`, `WaterPurification` | +| Freezer room | One placeholder storage Asset because the public page does not identify each reservable unit | `ColdStorage` with documented temperatures | +| First-floor plant lab | Four programmable growth chambers, biosafety cabinet, and autoclave | `PlantGrowth`, `BiosafetyContainment`, `SteamSterilization` | + +The public page also lists miscellaneous shared equipment such as pipettes, vortexers, water baths, centrifuges, a stereomicroscope, and an ice machine. The reference graph does not invent independently reservable Asset identities for those items when the public source does not provide them. + +## How the physical infrastructure becomes SBOLInventory + +| Public or operational fact | SBOLInventory representation in `inventory/ebef.ttl` | +| --- | --- | +| The EBEF is one governed laboratory resource | One `fac:Facility` with identity `https://example.org/ebef/facility` | +| Basement and first-floor laboratory areas contain more specific work areas | `fac:Zone` objects connected with `fac:parentZone` | +| An instrument or controlled chamber is installed in a place | A `fac:Asset` with `fac:locatedIn` pointing to the containing Zone | +| An anaerobic chamber or plant-growth cabinet creates a controlled interior | The controller Asset points to a distinct Zone with `fac:establishesZone` | +| The Microlab Prep and potentiostat are physically inside chamber 1 | Their `fac:locatedIn` values point to the chamber 1 interior, not merely the microbiology room | +| The ProFlex has three independently runnable blocks | One parent Asset plus three `fac:FunctionalUnit` child Assets connected with `fac:partOf`; thermal-cycling offerings belong to the child blocks | +| An installed Asset can perform an operation | The Asset owns a `fac:CapabilityOffering` whose `fac:capabilityKind` is a stable `cap:` IRI | +| The public source gives a capacity, temperature, atmosphere, or feature | The offering or Zone owns typed `fac:PropertyValue` objects, using QUDT unit IRIs where applicable | +| The source describes several units but does not identify each one | A clearly labeled grouped placeholder Asset is used instead of inventing serializable unit identities | + +For example, this shortened Turtle fragment captures the distinction between the chamber, the environment it establishes, and the instrument inside that environment: + +```turtle +@prefix cap: . +@prefix ex: . +@prefix fac: . +@prefix sbol: . + +ex:anaerobic_chamber_1 + a sbol:TopLevel, fac:Asset ; + fac:locatedIn ex:microbiology_lab ; + fac:establishesZone ex:anaerobic_chamber_1_interior . + +ex:anaerobic_chamber_1_interior + a sbol:TopLevel, fac:Zone ; + fac:parentZone ex:microbiology_lab ; + fac:zoneKind fac:ContainmentZone . + +ex:microlab_prep + a sbol:TopLevel, fac:Asset ; + fac:locatedIn ex:anaerobic_chamber_1_interior ; + fac:capability . + + + a sbol:Identified, fac:CapabilityOffering ; + fac:capabilityKind cap:LiquidHandling ; + fac:qualification fac:Described ; + fac:controlMode fac:UnspecifiedControl . +``` + +## How Lab loads the inventory + +There is no separate `inventory.toml` model. [`lab.toml`](lab.toml) contains only the package configuration and a package-relative pointer to the actual SBOLInventory RDF document: + +```toml +[inventory] +document = "inventory/ebef.ttl" +``` + +Lab infers Turtle from the `.ttl` extension, validates the document as both SBOL 3 and SBOLInventory Profile 0.2, and selects its Facility. The `facility` selector is omitted because [`inventory/ebef.ttl`](inventory/ebef.ttl) contains exactly one Facility. An equivalent explicit selection would be: + +```toml +[inventory] +document = "inventory/ebef.ttl" +facility = "https://example.org/ebef/facility" +``` + +The package intentionally has no `[[execution.adapters]]` entries. A public equipment description does not establish an installed control path, adapter configuration, credentials, or permission to operate hardware. Consequently, every public capability offering is `fac:Described` with `fac:UnspecifiedControl`; none is silently promoted to plannable or executable. + +Run the validation path from the repository root with: + +```bash +lab check examples/ebef +``` + +## Provenance and limits + +The catalog was generated by `sbol-inventory`'s `ebef_catalog` example at sbol-rs revision `2ecae3718ebb87dbdbf7112ed4d7f42c0155eea4`, using SBOLInventory Profile 0.2 artifacts from revision `7d8cb750dd2d5e3c6c7602e575c3a551b890724f`. It contains one facility, 12 zones, 28 assets, and 30 capability offerings. + +This is an architectural example, not an operational source of truth. It omits serial numbers, exact room and deck positions, network details, booking state, access-control policy, calibration and maintenance records, material lots, runtime adapters, and execution claims. The graph uses `fac:isActive true` to model catalog availability in this illustrative snapshot, but the public web page is not a live availability system; the value must not be treated as evidence that an instrument is presently bookable, calibrated, or safe to use. + +The graph is generated from the typed Rust authoring example rather than maintained by hand. Its source page and access date are preserved in the Facility description and provenance, while the exact generated-file SHA-256 is pinned by the `lab-inventory` EBEF test. + +## From description to simulation + +The [acceptance scenario](acceptance/README.md) extends this graph in isolation with explicitly synthetic simulation Assets. It exercises a multi-Asset plate growth and absorbance plan without changing the qualification or control-mode claims on EBEF's physical equipment. diff --git a/examples/ebef/acceptance/README.md b/examples/ebef/acceptance/README.md new file mode 100644 index 0000000..4f0a51a --- /dev/null +++ b/examples/ebef/acceptance/README.md @@ -0,0 +1,7 @@ +# EBEF-derived facility acceptance + +This fixture extends the public-data EBEF catalog with two explicitly synthetic, no-hardware Assets. The Assets are digital twins shaped by the cataloged Microlab Prep and Epoch 2, but they do not change the physical Assets' `Described` qualification or `UnspecifiedControl` mode. + +The acceptance test materializes the public catalog and this RDF extension as one Profile 0.2 document, binds liquid handling, incubation, and absorbance requirements to exact `Simulatable` offerings, moves one exact MaterialLot between the bound Assets, and executes reviewed `lab.simulation-run.v1` documents through the `lab.simulator` adapter. + +The test proves eager preflight, deterministic multi-Asset execution, exact resume behavior, source-inventory immutability, mode-bound ledgers, and conformant simulation provenance. It performs no hardware I/O and does not assert that Lab can control the installed EBEF instruments. diff --git a/examples/ebef/acceptance/simulation-extension.ttl b/examples/ebef/acceptance/simulation-extension.ttl new file mode 100644 index 0000000..c3432d2 --- /dev/null +++ b/examples/ebef/acceptance/simulation-extension.ttl @@ -0,0 +1,61 @@ + "A no-hardware digital twin shaped by the public Microlab Prep description. It makes no claim about the installed instrument's control path." ; + "microlab_prep_simulator" ; + ; + "EBEF Microlab Prep simulation asset" ; + a , ; + ; + ; + ; + ; + true ; + . + + "liquid_handling" ; + a , ; + ; + ; + true ; + . + + "A no-hardware digital twin shaped by the public Epoch 2 description. It makes no claim about the installed instrument's control path." ; + "epoch_2_simulator" ; + ; + "EBEF Epoch 2 simulation asset" ; + a , ; + ; + ; + , ; + ; + true ; + . + + "incubation" ; + a , ; + ; + ; + true ; + . + + "absorbance_measurement" ; + a , ; + ; + ; + true ; + . + + "assay_plate_design" ; + ; + "Plate growth and absorbance assay design" ; + a ; + . + + ; + "Synthetic input lot used only by the automated no-hardware acceptance scenario." ; + "assay_plate_lot" ; + ; + "Simulated assay plate lot" ; + a ; + ; + true ; + ; + . diff --git a/examples/ebef/inventory/ebef.ttl b/examples/ebef/inventory/ebef.ttl new file mode 100644 index 0000000..000641e --- /dev/null +++ b/examples/ebef/inventory/ebef.ttl @@ -0,0 +1,786 @@ + "amsco_630ls" ; + ; + "Large basement autoclave" ; + a , ; + ; + ; + ; + true ; + ; + "AMSCO 630LS" . + "steam_sterilization" ; + a , ; + ; + ; + true ; + . + "anaerobic_chamber_1" ; + ; + "Anaerobic chamber 1" ; + a , ; + ; + ; + ; + ; + true ; + ; + "Coy Laboratory Products" ; + "Vinyl anaerobic chamber" . + "anaerobic_environment_control" ; + a , ; + ; + ; + true ; + . + "anaerobic_chamber_1_interior" ; + ; + "Anaerobic chamber 1 interior" ; + a , ; + , , ; + ; + true ; + ; + . + "hydrogen_fraction" ; + a , ; + ; + "5"^^ ; + . + "maximum_added_co2" ; + a , ; + ; + "20"^^ ; + . + "nitrogen_fraction" ; + a , ; + ; + "95"^^ ; + . + "anaerobic_chamber_2" ; + ; + "Anaerobic chamber 2" ; + a , ; + ; + ; + ; + ; + true ; + ; + "Coy Laboratory Products" ; + "Extra-wide vinyl anaerobic chamber" . + "anaerobic_environment_control" ; + a , ; + ; + ; + true ; + . + "anaerobic_chamber_2_interior" ; + ; + "Anaerobic chamber 2 interior" ; + a , ; + , , ; + ; + true ; + ; + . + "hydrogen_fraction" ; + a , ; + ; + "5"^^ ; + . + "maximum_added_co2" ; + a , ; + ; + "20"^^ ; + . + "nitrogen_fraction" ; + a , ; + ; + "95"^^ ; + . + "anaerobic_swinging_bucket_centrifuge" ; + ; + "Anaerobic swinging-bucket centrifuge" ; + a , ; + ; + ; + ; + true ; + . + "centrifugation" ; + a , ; + ; + ; + true ; + . + "azure_300" ; + ; + "Gel imager" ; + a , ; + ; + ; + ; + true ; + ; + "Azure 300" . + "gel_imaging" ; + a , ; + ; + ; + true ; + . + "basement_main_lab" ; + ; + "Main lab (basement)" ; + a , ; + ; + true ; + . + "biotek_epoch_2" ; + ; + "Epoch 2 plate reader" ; + a , ; + ; + , ; + ; + true ; + ; + "Agilent BioTek" ; + "Epoch 2" . + "absorbance_measurement" ; + a , ; + ; + ; + true ; + , ; + . + "supported_plate_wells" ; + a , ; + 96 ; + . + "supports_fluorescence" ; + a , ; + false ; + . + "incubation" ; + a , ; + ; + ; + true ; + ; + . + "maximum_temperature" ; + a , ; + ; + "65"^^ ; + . + "Placeholder group pending identifiers for each reservable unit." ; + "cold_storage_group" ; + ; + "Publicly documented cold-storage units" ; + a , ; + ; + ; + ; + true ; + . + "cold_storage" ; + a , ; + ; + ; + true ; + ; + . + "documented_temperatures" ; + a , ; + ; + "4 C, -20 C, and -70 C" . + "conviron_gen1000_1" ; + ; + "Plant chamber Gen1000 1" ; + a , ; + ; + ; + ; + ; + true ; + ; + "Conviron" ; + "Gen1000" . + "plant_growth" ; + a , ; + ; + ; + true ; + , , , ; + . + "additive_co2" ; + a , ; + true ; + . + "additive_humidity" ; + a , ; + true ; + . + "programmable_light" ; + a , ; + true ; + . + "programmable_temperature" ; + a , ; + true ; + . + "conviron_gen1000_1_interior" ; + ; + "Plant chamber Gen1000 1 interior" ; + a , ; + ; + true ; + ; + . + "conviron_gen1000_2" ; + ; + "Plant chamber Gen1000 2" ; + a , ; + ; + ; + ; + ; + true ; + ; + "Conviron" ; + "Gen1000" . + "plant_growth" ; + a , ; + ; + ; + true ; + , , , ; + . + "additive_co2" ; + a , ; + true ; + . + "additive_humidity" ; + a , ; + true ; + . + "programmable_light" ; + a , ; + true ; + . + "programmable_temperature" ; + a , ; + true ; + . + "conviron_gen1000_2_interior" ; + ; + "Plant chamber Gen1000 2 interior" ; + a , ; + ; + true ; + ; + . + "conviron_gen2000" ; + ; + "Plant chamber Gen2000" ; + a , ; + ; + ; + ; + ; + true ; + ; + "Conviron" ; + "Gen2000" . + "plant_growth" ; + a , ; + ; + ; + true ; + , , , ; + . + "additive_co2" ; + a , ; + true ; + . + "additive_humidity" ; + a , ; + true ; + . + "programmable_light" ; + a , ; + true ; + . + "programmable_temperature" ; + a , ; + true ; + . + "conviron_gen2000_interior" ; + ; + "Plant chamber Gen2000 interior" ; + a , ; + ; + true ; + ; + . + "conviron_gr48" ; + ; + "Walk-in plant chamber" ; + a , ; + ; + ; + ; + ; + true ; + ; + "Conviron" ; + "GR48" . + "plant_growth" ; + a , ; + ; + ; + true ; + , , , ; + . + "additive_co2" ; + a , ; + true ; + . + "additive_humidity" ; + a , ; + true ; + . + "programmable_light" ; + a , ; + true ; + . + "programmable_temperature" ; + a , ; + true ; + . + "conviron_gr48_interior" ; + ; + "Walk-in plant chamber interior" ; + a , ; + ; + true ; + ; + . + "dragonfly_confocal" ; + ; + "Dragonfly spinning disk confocal microscope" ; + a , ; + ; + ; + ; + true ; + ; + "Dragonfly spinning disk confocal" . + "confocal_microscopy" ; + a , ; + ; + ; + true ; + , , ; + . + "camera_pixels_x" ; + a , ; + 2048 ; + . + "camera_pixels_y" ; + a , ; + 2048 ; + . + "supports_timelapse" ; + a , ; + true ; + . + "electrophoresis_station" ; + ; + "DNA and protein electrophoresis station" ; + a , ; + ; + ; + ; + true ; + . + "electrophoresis" ; + a , ; + ; + ; + true ; + . + "eppendorf_s44i_1" ; + ; + "Shaking incubator 1" ; + a , ; + ; + ; + ; + true ; + ; + "Eppendorf" ; + "S44i" . + "shaking_incubation" ; + a , ; + ; + ; + true ; + , , ; + . + "minimum_temperature" ; + a , ; + ; + "30"^^ ; + . + "supports_photosynthetic_lighting" ; + a , ; + false ; + . + "supports_refrigeration" ; + a , ; + false ; + . + "eppendorf_s44i_2" ; + ; + "Shaking incubator 2" ; + a , ; + ; + ; + ; + true ; + ; + "Eppendorf" ; + "S44i" . + "shaking_incubation" ; + a , ; + ; + ; + true ; + , ; + . + "supports_photosynthetic_lighting" ; + a , ; + true ; + . + "supports_refrigeration" ; + a , ; + true ; + . + "eppendorf_s44i_3" ; + ; + "Shaking incubator 3" ; + a , ; + ; + ; + ; + true ; + ; + "Eppendorf" ; + "S44i" . + "shaking_incubation" ; + a , ; + ; + ; + true ; + , ; + . + "supports_photosynthetic_lighting" ; + a , ; + true ; + . + "supports_refrigeration" ; + a , ; + true ; + . + "Public-data example catalog for a multi-user BSL2+ microbiology, microscopy, and plant cultivation facility; source accessed 2026-08-26." ; + "facility" ; + ; + "Resnick Ecology and Biosphere Engineering Facility" ; + a , ; + . + "freezer_room" ; + ; + "Freezer room" ; + a , ; + ; + true ; + ; + . + "main_biosafety_cabinet" ; + ; + "Main-lab biosafety cabinet" ; + a , ; + ; + ; + ; + true ; + . + "biosafety_containment" ; + a , ; + ; + ; + true ; + ; + . + "width_feet" ; + a , ; + ; + "6"^^ . + "media_prep_room" ; + ; + "Media preparation room" ; + a , ; + ; + true ; + ; + . + "media_prep_station" ; + ; + "Media and buffer preparation station" ; + a , ; + ; + , , ; + ; + true ; + . + "media_preparation" ; + a , ; + ; + ; + true ; + . + "ph_measurement" ; + a , ; + ; + ; + true ; + . + "water_purification" ; + a , ; + ; + ; + true ; + . + "microbiology_lab" ; + ; + "Microbiology lab (basement)" ; + a , ; + ; + true ; + ; + . + "microlab_prep" ; + ; + "Anaerobic liquid handler" ; + a , ; + ; + ; + ; + true ; + ; + "Hamilton" ; + "Microlab Prep" . + "liquid_handling" ; + a , ; + ; + ; + true ; + , ; + . + "supported_plate_wells" ; + a , ; + 96 ; + . + "supports_serial_dilution" ; + a , ; + true ; + . + "microscopy_lab" ; + ; + "Microscopy lab (basement)" ; + a , ; + ; + true ; + ; + . + "plant_autoclave" ; + ; + "Plant-lab soil and waste autoclave" ; + a , ; + ; + ; + ; + true ; + . + "steam_sterilization" ; + a , ; + ; + ; + true ; + ; + . + "optional_effluent_decontamination" ; + a , ; + true ; + . + "plant_biosafety_cabinet" ; + ; + "Plant-lab biosafety cabinet" ; + a , ; + ; + ; + ; + true ; + . + "biosafety_containment" ; + a , ; + ; + ; + true ; + ; + . + "width_feet" ; + a , ; + ; + "4"^^ . + "plant_lab" ; + ; + "Plant lab (first floor)" ; + a , ; + ; + true ; + . + "plasma_cleaner" ; + ; + "Plasma cleaner" ; + a , ; + ; + ; + ; + true ; + . + "plasma_cleaning" ; + a , ; + ; + ; + true ; + . + "potentiostat_96_well" ; + ; + "96-well potentiostat" ; + a , ; + ; + ; + ; + true ; + . + "electrochemical_measurement" ; + a , ; + ; + ; + true ; + ; + . + "supported_plate_wells" ; + a , ; + 96 ; + . + "Composite parent; independently runnable blocks are child assets." ; + "proflex" ; + ; + "ProFlex thermocycler" ; + a , ; + ; + ; + true ; + ; + "ProFlex PCR System" . + "proflex_block_1" ; + ; + "ProFlex independent block 1" ; + a , ; + ; + ; + ; + true ; + . + "thermal_cycling" ; + a , ; + ; + ; + true ; + ; + . + "temperature_zones" ; + a , ; + 2 ; + . + "proflex_block_2" ; + ; + "ProFlex independent block 2" ; + a , ; + ; + ; + ; + true ; + . + "thermal_cycling" ; + a , ; + ; + ; + true ; + ; + . + "temperature_zones" ; + a , ; + 2 ; + . + "proflex_block_3" ; + ; + "ProFlex independent block 3" ; + a , ; + ; + ; + ; + true ; + . + "thermal_cycling" ; + a , ; + ; + ; + true ; + ; + . + "temperature_zones" ; + a , ; + 2 ; + . + "Public page describes several units; individual asset IDs are not public." ; + "static_incubator_group" ; + ; + "Static incubators" ; + a , ; + ; + ; + ; + true ; + . + "static_incubation" ; + a , ; + ; + ; + true ; + , ; + . + "maximum_temperature" ; + a , ; + ; + "70"^^ ; + . + "minimum_temperature" ; + a , ; + ; + "17"^^ ; + . diff --git a/examples/ebef/lab.toml b/examples/ebef/lab.toml new file mode 100644 index 0000000..63e85d7 --- /dev/null +++ b/examples/ebef/lab.toml @@ -0,0 +1,9 @@ +[package] +name = "ebef-reference" +version = "0.1.0" +edition = "2026" + +[inventory] +# This points to the SBOLInventory RDF graph; facility facts do not live in TOML. +# The selector is omitted because the document contains exactly one Facility. +document = "inventory/ebef.ttl" diff --git a/examples/ebef/src/facility.lab b/examples/ebef/src/facility.lab new file mode 100644 index 0000000..b61bc48 --- /dev/null +++ b/examples/ebef/src/facility.lab @@ -0,0 +1,5 @@ +/*! + * This package exercises Lab's portable SBOLInventory ingestion against the + * public-data EBEF reference catalog. It intentionally declares no workflow: + * public equipment descriptions are not operational adapter bindings. + */ diff --git a/examples/golden-gate-extended/README.md b/examples/golden-gate-extended/README.md index ee420dc..8abd5d2 100644 --- a/examples/golden-gate-extended/README.md +++ b/examples/golden-gate-extended/README.md @@ -1,50 +1,27 @@ # golden-gate-extended -A four-strain reporter panel, written to exercise most of the language rather -than the shortest path to a protocol. The smaller [`golden-gate`](../golden-gate) -example is the one to read first; this one is what the same laboratory looks like -once the interesting parts are in. +A four-strain reporter panel written to exercise most of the language rather than the shortest path to a protocol. The smaller [`golden-gate`](../golden-gate) example is the one to read first; this one is what the same laboratory looks like once the interesting parts are in. ```bash +lab check lab build +lab run .lab/build --dry-run ``` +`lab build` emits portable experiment artifacts, consumes `inventory/facility.ttl`, binds the reachable requirements across the exact Opentrons OT-2 and manual-workstation offerings, resolves the ordered reference plasmid through its exact MaterialLot, and derives five OT-2 protocols and the operator PDFs through the Asset's installed adapter. It prints every emitted Asset bundle, protocol, document, and reviewed-plan path. + ## What it shows -**Provenance per thing.** Two plasmids are assembled here and one is ordered from -a repository. Being built is a fact about a particular plasmid rather than about -plasmids, so `build plasmid` and `buy plasmid` declare the same kind of thing and -only differ in where it came from. `buy restriction_enzyme BsaI:` carries its own -datasheet — the temperature a digest runs at belongs to the enzyme, so no design -repeats it. - -**Generics.** `regulated_expression` is written once and works for any signal. -The panel type `List>` says the -trigger varies and the product does not, which is what makes three readings -comparable. `characterize` keeps the signal named, so inducing a tet-responsive -circuit with arabinose is a type error rather than a wasted plate. - -**A package extending the vocabulary.** `Isopropylthiogalactoside` is a signal -the standard library never heard of. Declaring it is all it takes: a promoter for -it can be bought, a circuit can respond to it, and the compiler refuses to induce -that circuit with anything else. - -**Reaction chemistry from three places.** What a plasmid *is* comes from -`std.bio.designs`; what Golden Gate needs to build one comes from -`std.bio.golden_gate`; what this bench has comes from `targets/`. A design states -only what departs from them. - -**Evidence.** `across 3 biological replicates` says what a claim is believed on. -Three measurements of one colony are one biological replicate however many times -they are repeated, and the compiler knows the difference because it knows where -each sample came from. - -**Reacting rather than waiting.** A plate is ready when enough colonies have -appeared, not on a schedule, so `await_colonies` images on a timer and finishes -on whichever comes first. - -**Fetching, without asking where it came from.** `provision reference_gfp` takes -the ordered plasmid off the shelf the same way `provision BL21` takes competent -cells. It does not consult provenance, and deliberately: a plasmid this -laboratory bought and one it assembled last month are both simply available, and -which is which is a question for the manifest rather than for a workflow. +**Provenance per thing.** Two plasmids are assembled here and one is ordered from a repository. Being built is a fact about a particular plasmid rather than about plasmids, so `build plasmid` and `buy plasmid` declare the same kind of thing and only differ in where it came from. `buy restriction_enzyme BsaI:` carries its own datasheet: the temperature a digest runs at belongs to the enzyme, so no design repeats it. + +**Generics.** `regulated_expression` is written once and works for any signal. The panel type `List>` says the trigger varies and the product does not, which is what makes three readings comparable. `characterize` keeps the signal named, so inducing a tet-responsive circuit with arabinose is a type error rather than a wasted plate. + +**A package extending the vocabulary.** `Isopropylthiogalactoside` is a signal the standard library never heard of. Declaring it is all it takes: a promoter for it can be bought, a circuit can respond to it, and the compiler refuses to induce that circuit with anything else. + +**Reaction chemistry and facility realization.** What a plasmid is comes from `std.bio.designs`; what Golden Gate needs to build one comes from `std.bio.golden_gate`; what this laboratory can perform comes from `inventory/facility.ttl`. The operational overlay binds Lab's adapter implementation to one exact Asset without introducing another target model. + +**Evidence.** `across 3 biological replicates` says what a claim is believed on. Three measurements of one colony are one biological replicate however many times they are repeated, and the compiler knows the difference because it knows where each sample came from. + +**Reacting rather than waiting.** A plate is ready when enough colonies have appeared, not on a schedule, so `await_colonies` images on a timer and finishes on whichever comes first. + +**Fetching without asking where it came from.** `provision reference_gfp` takes the ordered plasmid off the shelf the same way `provision BL21` takes competent cells. It does not consult provenance, deliberately: a plasmid this laboratory bought and one it assembled last month are both simply available, and which is which is a question for the inventory rather than for a workflow. diff --git a/examples/golden-gate/targets/opentrons-ot2.toml b/examples/golden-gate-extended/adapters/opentrons-ot2.toml similarity index 71% rename from examples/golden-gate/targets/opentrons-ot2.toml rename to examples/golden-gate-extended/adapters/opentrons-ot2.toml index fff2b50..0ab27bf 100644 --- a/examples/golden-gate/targets/opentrons-ot2.toml +++ b/examples/golden-gate-extended/adapters/opentrons-ot2.toml @@ -1,18 +1,8 @@ -# The bench this workspace compiles for. Its filename is its name: this is -# `opentrons-ot2`, built by `lab build --target opentrons-ot2` or by the -# `[build] target` the manifest declares. -# -# Everything here describes the laboratory, not the science: which modules are -# installed, what labware sits in which deck slot, and which pipette is on -# which mount. Another laboratory running the same programs supplies its own -# profile and changes no Lab source. -# -# Every field has a default matching the reference bench, so a profile states -# only what differs. This one is written out in full because it is the -# example's subject. +# Checked implementation configuration for the exact OT-2 Asset binding in `lab.toml`. +# Facility identity, composition, and capabilities belong to `inventory/facility.ttl`; this file cannot select an Asset or adapter. +# The current lowerer still needs device-specific protocol, layout, and labware mappings. Every field has a checked default; this example writes them out to make that residual implementation boundary explicit. -[target] -backend = "opentrons.ot2" +[protocol] api_level = "2.21" [instruments.small] diff --git a/examples/golden-gate-extended/inventory/facility.ttl b/examples/golden-gate-extended/inventory/facility.ttl new file mode 100644 index 0000000..20160f6 --- /dev/null +++ b/examples/golden-gate-extended/inventory/facility.ttl @@ -0,0 +1,459 @@ +@prefix cap: . +@prefix designs: . +@prefix ex: . +@prefix fac: . +@prefix igem: . +@prefix inv: . +@prefix lots: . +@prefix materials: . +@prefix sbol: . +@prefix xsd: . + +ex:facility + a sbol:TopLevel, fac:Facility ; + sbol:displayId "facility" ; + sbol:hasNamespace ; + sbol:name "Golden Gate example facility" . + +ex:lab + a sbol:TopLevel, fac:Zone ; + sbol:displayId "lab" ; + sbol:hasNamespace ; + sbol:name "Teaching laboratory" ; + fac:facility ex:facility ; + fac:zoneKind fac:Room ; + fac:isActive true . + +ex:automation_bench + a sbol:TopLevel, fac:Zone ; + sbol:displayId "automation_bench" ; + sbol:hasNamespace ; + sbol:name "Automation bench" ; + fac:facility ex:facility ; + fac:parentZone ex:lab ; + fac:zoneKind fac:WorkArea ; + fac:isActive true . + +ex:stock_storage + a sbol:TopLevel, fac:Zone ; + sbol:displayId "stock_storage" ; + sbol:hasNamespace ; + sbol:name "Golden Gate stock storage" ; + fac:facility ex:facility ; + fac:parentZone ex:lab ; + fac:zoneKind fac:StorageZone ; + fac:isActive true . + +ex:opentrons_ot2 + a sbol:TopLevel, fac:Asset ; + sbol:displayId "opentrons_ot2" ; + sbol:hasNamespace ; + sbol:name "Opentrons OT-2 with Thermocycler Module" ; + fac:facility ex:facility ; + fac:assetKind fac:Instrument ; + fac:locatedIn ex:automation_bench ; + fac:manufacturer "Opentrons" ; + fac:model "OT-2 with Thermocycler Module Gen2" ; + fac:isActive true ; + fac:capability ex:opentrons_ot2_liquid_handling, ex:opentrons_ot2_thermal_cycling . + +ex:opentrons_ot2_liquid_handling + a sbol:Identified, fac:CapabilityOffering ; + sbol:displayId "liquid_handling" ; + fac:capabilityKind cap:LiquidHandling ; + fac:qualification fac:Plannable ; + fac:controlMode fac:ReviewedFileControl ; + fac:isActive true . + +ex:opentrons_ot2_thermal_cycling + a sbol:Identified, fac:CapabilityOffering ; + sbol:displayId "thermal_cycling" ; + fac:capabilityKind cap:ThermalCycling ; + fac:qualification fac:Plannable ; + fac:controlMode fac:ReviewedFileControl ; + fac:isActive true . + +ex:manual_workstation + a sbol:TopLevel, fac:Asset ; + sbol:displayId "manual_workstation" ; + sbol:hasNamespace ; + sbol:name "Golden Gate manual workstation" ; + fac:facility ex:facility ; + fac:assetKind fac:Workstation ; + fac:locatedIn ex:automation_bench ; + fac:isActive true ; + fac:capability ex:manual_artifact_realization, + ex:manual_material_provisioning, + ex:manual_chemical_transformation, + ex:manual_incubation, + ex:manual_antibiotic_selection, + ex:manual_plate_imaging, + ex:manual_waste_handling . + +ex:manual_artifact_realization + a sbol:Identified, fac:CapabilityOffering ; + sbol:displayId "artifact_realization" ; + fac:capabilityKind cap:ArtifactRealization ; + fac:qualification fac:Plannable ; + fac:controlMode fac:ManualControl ; + fac:isActive true . + +ex:manual_material_provisioning + a sbol:Identified, fac:CapabilityOffering ; + sbol:displayId "material_provisioning" ; + fac:capabilityKind cap:MaterialProvisioning ; + fac:qualification fac:Plannable ; + fac:controlMode fac:ManualControl ; + fac:isActive true . + +ex:manual_chemical_transformation + a sbol:Identified, fac:CapabilityOffering ; + sbol:displayId "chemical_transformation" ; + fac:capabilityKind cap:ChemicalTransformation ; + fac:qualification fac:Plannable ; + fac:controlMode fac:ManualControl ; + fac:isActive true . + +ex:manual_incubation + a sbol:Identified, fac:CapabilityOffering ; + sbol:displayId "incubation" ; + fac:capabilityKind cap:Incubation ; + fac:qualification fac:Plannable ; + fac:controlMode fac:ManualControl ; + fac:isActive true ; + fac:parameter ex:manual_incubation_duration . + +ex:manual_incubation_duration + a sbol:Identified, fac:PropertyValue ; + sbol:displayId "duration" ; + fac:propertyKind cap:Duration ; + fac:realValue "1"^^xsd:double ; + fac:unit . + +ex:manual_antibiotic_selection + a sbol:Identified, fac:CapabilityOffering ; + sbol:displayId "antibiotic_selection" ; + fac:capabilityKind cap:AntibioticSelection ; + fac:qualification fac:Plannable ; + fac:controlMode fac:ManualControl ; + fac:isActive true . + +ex:manual_plate_imaging + a sbol:Identified, fac:CapabilityOffering ; + sbol:displayId "plate_imaging" ; + fac:capabilityKind cap:PlateImaging ; + fac:qualification fac:Plannable ; + fac:controlMode fac:ManualControl ; + fac:isActive true . + +ex:manual_waste_handling + a sbol:Identified, fac:CapabilityOffering ; + sbol:displayId "waste_handling" ; + fac:capabilityKind cap:WasteHandling ; + fac:qualification fac:Plannable ; + fac:controlMode fac:ManualControl ; + fac:isActive true . + +igem:J23101 + a sbol:Component ; + sbol:displayId "J23101" ; + sbol:hasNamespace ; + sbol:type . + +igem:J23106 + a sbol:Component ; + sbol:displayId "J23106" ; + sbol:hasNamespace ; + sbol:type . + +igem:B0034 + a sbol:Component ; + sbol:displayId "B0034" ; + sbol:hasNamespace ; + sbol:type . + +igem:B0015 + a sbol:Component ; + sbol:displayId "B0015" ; + sbol:hasNamespace ; + sbol:type . + +igem:GFP + a sbol:Component ; + sbol:displayId "GFP" ; + sbol:hasNamespace ; + sbol:type . + +igem:RFP + a sbol:Component ; + sbol:displayId "RFP" ; + sbol:hasNamespace ; + sbol:type . + +materials:pSB1C3 + a sbol:Component ; + sbol:displayId "pSB1C3" ; + sbol:hasNamespace ; + sbol:type . + +materials:BsaI + a sbol:Component ; + sbol:displayId "BsaI" ; + sbol:hasNamespace ; + sbol:type . + +materials:T4_DNA_ligase + a sbol:Component ; + sbol:displayId "T4_DNA_ligase" ; + sbol:hasNamespace ; + sbol:type . + +materials:T4_DNA_ligase_buffer + a sbol:Component ; + sbol:displayId "T4_DNA_ligase_buffer" ; + sbol:hasNamespace ; + sbol:type . + +materials:nuclease_free_water + a sbol:Component ; + sbol:displayId "nuclease_free_water" ; + sbol:hasNamespace ; + sbol:type . + +materials:recovery_medium + a sbol:Component ; + sbol:displayId "recovery_medium" ; + sbol:hasNamespace ; + sbol:type . + +materials:DH5alpha + a sbol:Component ; + sbol:displayId "DH5alpha" ; + sbol:hasNamespace ; + sbol:type . + +materials:BL21 + a sbol:Component ; + sbol:displayId "BL21" ; + sbol:hasNamespace ; + sbol:type . + +materials:chloramphenicol + a sbol:Component ; + sbol:displayId "chloramphenicol" ; + sbol:hasNamespace ; + sbol:type . + +materials:reference_gfp + a sbol:Component ; + sbol:displayId "reference_gfp" ; + sbol:hasNamespace ; + sbol:type . + +designs:composite_plasmid_1 + a sbol:Component ; + sbol:displayId "composite_plasmid_1" ; + sbol:hasNamespace ; + sbol:type . + +designs:composite_plasmid_2 + a sbol:Component ; + sbol:displayId "composite_plasmid_2" ; + sbol:hasNamespace ; + sbol:type . + +designs:composite_strain_1 + a sbol:Component ; + sbol:displayId "composite_strain_1" ; + sbol:hasNamespace ; + sbol:type . + +designs:composite_strain_2 + a sbol:Component ; + sbol:displayId "composite_strain_2" ; + sbol:hasNamespace ; + sbol:type . + +designs:composite_strain_3 + a sbol:Component ; + sbol:displayId "composite_strain_3" ; + sbol:hasNamespace ; + sbol:type . + +designs:composite_strain_4 + a sbol:Component ; + sbol:displayId "composite_strain_4" ; + sbol:hasNamespace ; + sbol:type . + +designs:expression_strain + a sbol:Component ; + sbol:displayId "expression_strain" ; + sbol:hasNamespace ; + sbol:type . + +designs:reference_strain + a sbol:Component ; + sbol:displayId "reference_strain" ; + sbol:hasNamespace ; + sbol:type . + +lots:J23101_lot + a sbol:Implementation ; + sbol:displayId "J23101_lot" ; + sbol:hasNamespace ; + sbol:built igem:J23101 ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:J23106_lot + a sbol:Implementation ; + sbol:displayId "J23106_lot" ; + sbol:hasNamespace ; + sbol:built igem:J23106 ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:B0034_lot + a sbol:Implementation ; + sbol:displayId "B0034_lot" ; + sbol:hasNamespace ; + sbol:built igem:B0034 ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:B0015_lot + a sbol:Implementation ; + sbol:displayId "B0015_lot" ; + sbol:hasNamespace ; + sbol:built igem:B0015 ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:GFP_lot + a sbol:Implementation ; + sbol:displayId "GFP_lot" ; + sbol:hasNamespace ; + sbol:built igem:GFP ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:RFP_lot + a sbol:Implementation ; + sbol:displayId "RFP_lot" ; + sbol:hasNamespace ; + sbol:built igem:RFP ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:pSB1C3_lot + a sbol:Implementation ; + sbol:displayId "pSB1C3_lot" ; + sbol:hasNamespace ; + sbol:built materials:pSB1C3 ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:BsaI_lot + a sbol:Implementation ; + sbol:displayId "BsaI_lot" ; + sbol:hasNamespace ; + sbol:built materials:BsaI ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:T4_DNA_ligase_lot + a sbol:Implementation ; + sbol:displayId "T4_DNA_ligase_lot" ; + sbol:hasNamespace ; + sbol:built materials:T4_DNA_ligase ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:T4_DNA_ligase_buffer_lot + a sbol:Implementation ; + sbol:displayId "T4_DNA_ligase_buffer_lot" ; + sbol:hasNamespace ; + sbol:built materials:T4_DNA_ligase_buffer ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:nuclease_free_water_lot + a sbol:Implementation ; + sbol:displayId "nuclease_free_water_lot" ; + sbol:hasNamespace ; + sbol:built materials:nuclease_free_water ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:recovery_medium_lot + a sbol:Implementation ; + sbol:displayId "recovery_medium_lot" ; + sbol:hasNamespace ; + sbol:built materials:recovery_medium ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:DH5alpha_lot + a sbol:Implementation ; + sbol:displayId "DH5alpha_lot" ; + sbol:hasNamespace ; + sbol:built materials:DH5alpha ; + fac:materialKind inv:BacterialStock ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:BL21_lot + a sbol:Implementation ; + sbol:displayId "BL21_lot" ; + sbol:hasNamespace ; + sbol:built materials:BL21 ; + fac:materialKind inv:BacterialStock ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:chloramphenicol_lot + a sbol:Implementation ; + sbol:displayId "chloramphenicol_lot" ; + sbol:hasNamespace ; + sbol:built materials:chloramphenicol ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:reference_gfp_lot + a sbol:Implementation ; + sbol:displayId "reference_gfp_lot" ; + sbol:hasNamespace ; + sbol:built materials:reference_gfp ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . diff --git a/examples/golden-gate-extended/lab.toml b/examples/golden-gate-extended/lab.toml index 6f4b3f0..b095600 100644 --- a/examples/golden-gate-extended/lab.toml +++ b/examples/golden-gate-extended/lab.toml @@ -5,30 +5,11 @@ edition = "2026" [build] entry = "src/programs/panel.lab" -target = "opentrons-ot2" [inventory] -materials = [ - "BL21", - # BsaI states a supplier identity, so that is what an order names. - "NEB-R0535", - "DH5alpha", - # The DNA parts are declared in SBOL, where a part's identity is the registry - # record it resolves to. That record is what an order names, so it is what is - # listed here rather than the bare symbol a Lab declaration would have used. - "https://synbiohub.org/public/igem/B0015", - "https://synbiohub.org/public/igem/B0034", - "https://synbiohub.org/public/igem/GFP", - "https://synbiohub.org/public/igem/J23101", - "https://synbiohub.org/public/igem/J23106", - "https://synbiohub.org/public/igem/RFP", - "T4_DNA_ligase", - "T4_DNA_ligase_buffer", - "chloramphenicol", - "nuclease_free_water", - "pSB1C3", - "recovery_medium", - # The reference plasmid is ordered, so the build asks for it as stock. - "Addgene-#134516", -] -artifacts = [] +document = "inventory/facility.ttl" + +[[execution.adapters]] +asset = "https://example.org/golden-gate/opentrons_ot2" +driver = "opentrons.ot2" +profile = "adapters/opentrons-ot2.toml" diff --git a/examples/golden-gate-extended/src/designs/inventory.lab b/examples/golden-gate-extended/src/designs/inventory.lab index 1b68881..0ed2816 100644 --- a/examples/golden-gate-extended/src/designs/inventory.lab +++ b/examples/golden-gate-extended/src/designs/inventory.lab @@ -13,27 +13,46 @@ use std.bio.designs +record Reagent + +artifact Reagent: + description?: String + buy: - backbone pSB1C3 + backbone pSB1C3: + sbol_identity = "https://example.org/golden-gate/materials/pSB1C3" /** BsaI cuts at 37 C, so every plasmid it opens digests the same way. */ restriction_enzyme BsaI: - identity = "NEB-R0535" + sbol_identity = "https://example.org/golden-gate/materials/BsaI" + supplier_identity = "NEB-R0535" digest_temperature = 37 C digest_duration = 2 min + reagent T4_DNA_ligase: + sbol_identity = "https://example.org/golden-gate/materials/T4_DNA_ligase" + reagent T4_DNA_ligase_buffer: + sbol_identity = "https://example.org/golden-gate/materials/T4_DNA_ligase_buffer" + reagent nuclease_free_water: + sbol_identity = "https://example.org/golden-gate/materials/nuclease_free_water" + reagent recovery_medium: + sbol_identity = "https://example.org/golden-gate/materials/recovery_medium" + // Host organisms. DH5alpha is a cloning strain; BL21 is an expression strain. // Both are transformed the way competent cells are: chilled, shocked, recovered. chassis DH5alpha: + sbol_identity = "https://example.org/golden-gate/materials/DH5alpha" heat_shock_temperature = 42 C cold_incubation = 30 min recovery_temperature = 37 C recovery_duration = 60 min chassis BL21: + sbol_identity = "https://example.org/golden-gate/materials/BL21" heat_shock_temperature = 42 C cold_incubation = 30 min recovery_temperature = 37 C recovery_duration = 60 min - antibiotic chloramphenicol + antibiotic chloramphenicol: + sbol_identity = "https://example.org/golden-gate/materials/chloramphenicol" diff --git a/examples/golden-gate-extended/src/designs/plasmids.lab b/examples/golden-gate-extended/src/designs/plasmids.lab index 0d5c44b..8d01a97 100644 --- a/examples/golden-gate-extended/src/designs/plasmids.lab +++ b/examples/golden-gate-extended/src/designs/plasmids.lab @@ -30,6 +30,7 @@ rfp_reporter_sequence: DNA = dna("TTTACGGCTAGCTCAGTCCTAGGTATAGTGCTAGCAAAGAGGAGAA * has to clear a bar on enough independent clones to mean something. */ build plasmid composite_plasmid_1: + sbol_identity = "https://example.org/golden-gate/designs/composite_plasmid_1" sequence = gfp_reporter_sequence backbone = pSB1C3 components = [J23101, B0034, GFP, B0015] @@ -60,6 +61,7 @@ build plasmid composite_plasmid_1: * is a property of the design rather than of the protocol that built it. */ build plasmid composite_plasmid_2: + sbol_identity = "https://example.org/golden-gate/designs/composite_plasmid_2" sequence = rfp_reporter_sequence backbone = pSB1C3 components = [J23106, B0034, RFP, B0015] @@ -89,5 +91,6 @@ build plasmid composite_plasmid_2: * above, and only its provenance differs. */ buy plasmid reference_gfp: - identity = "Addgene-#134516" + sbol_identity = "https://example.org/golden-gate/materials/reference_gfp" + supplier_identity = "Addgene-#134516" sequence = gfp_reporter_sequence diff --git a/examples/golden-gate-extended/src/designs/strains.lab b/examples/golden-gate-extended/src/designs/strains.lab index f229b2e..9880ea5 100644 --- a/examples/golden-gate-extended/src/designs/strains.lab +++ b/examples/golden-gate-extended/src/designs/strains.lab @@ -14,6 +14,7 @@ use golden_gate_extended.designs.plasmids /** The GFP reporter carried in the DH5alpha cloning strain. */ build strain composite_strain_1: + sbol_identity = "https://example.org/golden-gate/designs/composite_strain_1" chassis = DH5alpha plasmids = [composite_plasmid_1] selection = chloramphenicol @@ -31,6 +32,7 @@ build strain composite_strain_1: /** The RFP reporter in the same cloning strain. */ build strain composite_strain_2: + sbol_identity = "https://example.org/golden-gate/designs/composite_strain_2" chassis = DH5alpha plasmids = [composite_plasmid_2] selection = chloramphenicol @@ -48,6 +50,7 @@ build strain composite_strain_2: /** The GFP reporter in BL21, where it is expressed rather than stored. */ build strain expression_strain: + sbol_identity = "https://example.org/golden-gate/designs/expression_strain" chassis = BL21 plasmids = [composite_plasmid_1] selection = chloramphenicol @@ -70,6 +73,7 @@ build strain expression_strain: * is the point of stating provenance on the thing rather than on its type. */ build strain reference_strain: + sbol_identity = "https://example.org/golden-gate/designs/reference_strain" chassis = BL21 plasmids = [reference_gfp] selection = chloramphenicol diff --git a/examples/golden-gate-python/README.md b/examples/golden-gate-python/README.md index 156a649..e785951 100644 --- a/examples/golden-gate-python/README.md +++ b/examples/golden-gate-python/README.md @@ -18,4 +18,4 @@ python -m golden_gate The command imports every module in dependency order and passes them to `lab.check`. A compiler diagnostic points back to the Python declaration or workflow statement that produced it. -The current project CLI discovers written `.lab` and SBOL files, while Python modules enter through the SDK. This example therefore checks the same portable modules as the Lab version but does not invoke the target-specific `lab build` step. +The current project CLI discovers written `.lab` and SBOL files, while Python modules enter through the SDK. This example therefore checks the same portable modules as the Lab version but does not invoke package-oriented `lab build` or facility planning. diff --git a/examples/golden-gate/README.md b/examples/golden-gate/README.md index 9eaca6a..b5c8b7d 100644 --- a/examples/golden-gate/README.md +++ b/examples/golden-gate/README.md @@ -1,18 +1,12 @@ # Golden Gate cloning on an Opentrons OT-2 -This is Lab's end-to-end example: a package that describes a small reporter -panel biologically, and compiles it into the automation protocols that build it. +This is Lab's end-to-end facility example: a package describes a small reporter panel biologically, compiles it into portable capability requirements, allocates those requirements against an SBOLInventory facility, and lowers the resulting OT-2 bindings into automation protocols. -It reproduces the three-stage workflow from -[PUDU](https://pudu.readthedocs.io/en/latest/guide/workflow.html) — Golden Gate -assembly, heat-shock transformation, and serial dilution with selective plating -— from two composite plasmids into four engineered strains. +It reproduces the three-stage workflow from [PUDU](https://pudu.readthedocs.io/en/latest/guide/workflow.html): Golden Gate assembly, heat-shock transformation, and serial dilution with selective plating from two composite plasmids into four engineered strains. ## What it builds -Two transcription units, each a promoter driving a fluorescent reporter, are -assembled into the same backbone. Each is then introduced into two different -host organisms: +Two transcription units, each a promoter driving a fluorescent reporter, are assembled into the same backbone. Each is then introduced into two different host organisms: ```text composite_plasmid_1 (J23101 → GFP) composite_plasmid_2 (J23106 → RFP) @@ -20,137 +14,72 @@ composite_plasmid_1 (J23101 → GFP) composite_plasmid_2 (J23106 → RFP) └── composite_strain_3 (BL21) └── composite_strain_4 (BL21) ``` -One plasmid feeding two strains is the point of the example. A strain is its -own artifact, so DH5alpha carrying `composite_plasmid_1` and BL21 carrying the -same plasmid are two separate things to build and accept. Nothing in the source -says which order to build them in; the compiler derives that from the material -each workflow consumes. +One plasmid feeding two strains is the point of the example. A strain is its own artifact, so DH5alpha carrying `composite_plasmid_1` and BL21 carrying the same plasmid are two separate things to build and accept. Nothing in the source says which order to build them in; the compiler derives that from the material each workflow consumes. -The DNA sequences are first-class values declared independently of the designs -that reference them. Provenance is separate again: `buy` marks catalogued parts -and reagents, while `build` marks the plasmids and strains this laboratory makes. +The DNA sequences are first-class values declared independently of the designs that reference them. Provenance is separate again: `buy` marks catalogued parts and reagents, while `build` marks the plasmids and strains this laboratory makes. -## Build it +## Check and build the experiment -From the `examples/golden-gate` directory, run: +From `examples/golden-gate`, run: ```bash +lab check lab build ``` -The manifest declares `[build] target = "opentrons-ot2"`, so a plain -`lab build` compiles for the OT-2 bench. `lab build --target ` compiles -for another bench, and `lab build --no-target` stops at portable module IR. +`lab build` emits the checked module IR and reachable capability requirements, allocates them against `inventory/facility.ttl`, and derives the OT-2 protocol bundle and PDFs through the adapter bound to the selected Asset. Its output names each build product, Asset bundle, automation protocol, operator document, and reviewed plan path. -The build writes protocols under `.lab/build/opentrons-ot2/`, one directory -per planning wave, and prints the path of every runnable automation protocol. +## Facility-derived outputs -The build output holds, per target directory: - -| Path | Contents | -| --- | --- | -| `dependency_manifest.json` | machine-readable graph, waves, and blockers | -| `dependency_report.pdf` | typeset dependency and blocker summary (`.typ` source beside it) | -| `manual_protocol.pdf` | typeset bench instructions in execution order (`.typ` source beside it) | -| `lab-style.typ` | the shared document style; every directory holding a document carries a copy | -| `wave-001/` | assembly of both plasmids: one deck, one run | -| `wave-002/` | transformation and plating of all four strains | - -Each output directory is a self-contained [Typst](https://typst.app) project: -`lab build` typesets the PDFs in-process (fonts embedded, no network), and -anyone with the `typst` CLI can restyle `lab-style.typ` and re-typeset a -document without the Lab toolchain. - -Artifacts in the same wave have no ordering constraint between them, so a wave -is a single robot run over a single deck. Wave 2 cannot start until wave 1's -plasmids physically exist and have been accepted as suitable inputs. - -## Build it for a different instrument - -`targets/opentrons-flex.toml` describes an Opentrons Flex. It declares -`[target] backend = "opentrons.flex"`, and that key is what selects the -backend: +The package selects `inventory/facility.ttl`, a conformant SBOLInventory document containing the laboratory's zones, exact stock MaterialLots, a manual workstation, and an Opentrons OT-2 Asset with plannable liquid-handling and thermal-cycling offerings. The local adapter binding states that Lab's `opentrons.ot2` implementation can operate that exact Asset. ```bash -lab build --target opentrons-flex +lab run .lab/build --dry-run ``` -The same programs, designs, and inventory produce the same waves under -`.lab/build/opentrons-flex/`, with each stage emitted as an Opentrons JSON -protocol (schema 8) rather than Python. Verify them with: +The facility phase binds every reachable requirement to one exact CapabilityOffering and Asset. Because the allocated OT-2 has an installed lowering adapter, `lab build` emits three OT-2 Python protocols without reading a package target. `lab plan` remains available when only this facility phase should be written separately under `.lab/plan/`. -```bash -scripts/analyze-opentrons-flex.sh examples/golden-gate/.lab/build/opentrons-flex -``` +| Path | Contents | +| --- | --- | +| `.lab/build/facility_allocation.json` | requirement-to-offering-to-Asset allocation and rejected candidates | +| `.lab/build/facility_lowering.json` | exact Asset, adapter, profile digest, triggering requirements, emitted artifacts, and artifact digests | +| `.lab/build/plan.execution.json` | reviewed facility-wide DAG and hash-addressed adapter-lowering child bundle | +| `.lab/build/assets/opentrons_ot2/dependency_manifest.json` | material graph, exact MaterialLot bindings, waves, and blockers | +| `.lab/build/assets/opentrons_ot2/dependency_report.pdf` | typeset dependency and blocker summary | +| `.lab/build/assets/opentrons_ot2/manual_protocol.pdf` | typeset bench instructions in execution order | +| `.lab/build/assets/opentrons_ot2/wave-001/` | assembly of both plasmids | +| `.lab/build/assets/opentrons_ot2/wave-002/` | transformation and plating of all four strains | -`targets/hamilton-star.toml` describes a Hamilton STARlet. Its -`backend = "hamilton.star"` selects the firmware-protocol backend: +Artifacts in the same wave have no ordering constraint between them, so a wave is one robot run over one deck. Wave 2 cannot start until wave 1's plasmids physically exist and have been accepted as suitable inputs. -```bash -lab build --target hamilton-star -``` +The OT-2 offerings are `Plannable` with `ReviewedFileControl`. `lab run .lab/build --dry-run` verifies every frozen protocol and support-artifact digest before narrating the plan. The Execute nodes remain planning-only because the current OT-2 lowerer emits one whole-program bundle rather than an independently executable document per capability requirement; the example does not claim that this Asset is hardware-qualified for live execution. -Each wave then contains `*.star.json` run documents — ordered, reviewable -Hamilton firmware frames with an operator description per step — plus the -manual protocol that interleaves the off-deck thermal work. Review a wave -without hardware, or execute it on the connected machine: +## Use another instrument -```bash -lab run examples/golden-gate/.lab/build/hamilton-star/wave-001 --dry-run -``` - -`targets/workcell-star.toml` composes the same STARlet with an Inheco ODTC -thermocycler and a human carrying the plate between them. Its -`backend = "workcell"` selects the multi-station backend: +Another facility can run the same experiment by supplying an SBOLInventory document with compatible offerings and explicit adapter bindings for its exact Assets. Instrument choice is a facility-allocation result; the workflow does not use `--target` or name a backend. -```bash -lab build --target workcell-star -``` +## Inspect the OT-2 deck -Each wave then holds per-station packages under `stations/` and a -`plan.workcell.json` coordination plan: the STAR's runs, the thermal -programs that would otherwise be operator prose (now `*.odtc.json` -documents the cycler executes), and an explicit handoff node for every -plate movement. `lab run` walks the plan, gates every handoff on the -operator, and records each node in `run-ledger.jsonl` so an interrupted -wave continues with `--resume`: +Find the emitted protocols with: ```bash -lab run examples/golden-gate/.lab/build/workcell-star/wave-001 --dry-run +find .lab/build/assets -name '*_protocol.py' -print ``` -## See the deck - -`targets/opentrons-ot2.toml` describes an OT-2 (`lab build --target -opentrons-ot2` emits `*_protocol.py` under `.lab/build/opentrons-ot2/`). -Open the Opentrons app, go to **Protocols**, and either drag one of those -protocol files onto the window or use **Import a Protocol → Choose file** and -paste the path the build printed. The app analyzes it and draws the deck. +Open the Opentrons app, go to **Protocols**, and import one of those files. The app must have OT-2 support; use the 8.4.x app or the `Opentrons-OT2` build because a 9.x app rejects OT-2 protocols. -The app must have OT-2 support. Opentrons split that into a separate -application at version 9, so use the 8.4.x app or the `Opentrons-OT2` build; a -9.x app rejects these protocols with a message pointing at the OT-2 download. - -To check a protocol without the GUI, run the app's own analyzer over it: +To check a protocol without the GUI, run the app's analyzer over the selected file: ```bash /Applications/Opentrons.app/Contents/Resources/python/bin/python3.10 \ -m opentrons.cli analyze --json-output /tmp/analysis.json \ - examples/golden-gate/.lab/build/opentrons-ot2/wave-002/transformation_protocol.py + "$(find .lab/build/assets -name transformation_protocol.py -print -quit)" ``` -The JSON reports `errors` plus the full deck — modules, labware with slot -assignments, and pipettes with mounts — which is what the deck map renders. - -## Verify the generated code - -```bash -scripts/check-opentrons-target.sh examples/golden-gate/.lab/build/opentrons-ot2 -``` +To lint, typecheck, and simulate the complete emitted OT-2 package: ```bash -scripts/simulate-opentrons.sh examples/golden-gate/.lab/build/opentrons-ot2 +ot2_output=.lab/build/assets/opentrons_ot2 +../../scripts/check-opentrons-bundle.sh "$ot2_output" +../../scripts/simulate-opentrons.sh "$ot2_output" ``` - -The first lints and typechecks every emitted protocol; the second runs them -through the official Opentrons simulator. diff --git a/examples/golden-gate-extended/targets/opentrons-ot2.toml b/examples/golden-gate/adapters/opentrons-ot2.toml similarity index 71% rename from examples/golden-gate-extended/targets/opentrons-ot2.toml rename to examples/golden-gate/adapters/opentrons-ot2.toml index fff2b50..0ab27bf 100644 --- a/examples/golden-gate-extended/targets/opentrons-ot2.toml +++ b/examples/golden-gate/adapters/opentrons-ot2.toml @@ -1,18 +1,8 @@ -# The bench this workspace compiles for. Its filename is its name: this is -# `opentrons-ot2`, built by `lab build --target opentrons-ot2` or by the -# `[build] target` the manifest declares. -# -# Everything here describes the laboratory, not the science: which modules are -# installed, what labware sits in which deck slot, and which pipette is on -# which mount. Another laboratory running the same programs supplies its own -# profile and changes no Lab source. -# -# Every field has a default matching the reference bench, so a profile states -# only what differs. This one is written out in full because it is the -# example's subject. +# Checked implementation configuration for the exact OT-2 Asset binding in `lab.toml`. +# Facility identity, composition, and capabilities belong to `inventory/facility.ttl`; this file cannot select an Asset or adapter. +# The current lowerer still needs device-specific protocol, layout, and labware mappings. Every field has a checked default; this example writes them out to make that residual implementation boundary explicit. -[target] -backend = "opentrons.ot2" +[protocol] api_level = "2.21" [instruments.small] diff --git a/examples/golden-gate/inventory/facility.ttl b/examples/golden-gate/inventory/facility.ttl new file mode 100644 index 0000000..20160f6 --- /dev/null +++ b/examples/golden-gate/inventory/facility.ttl @@ -0,0 +1,459 @@ +@prefix cap: . +@prefix designs: . +@prefix ex: . +@prefix fac: . +@prefix igem: . +@prefix inv: . +@prefix lots: . +@prefix materials: . +@prefix sbol: . +@prefix xsd: . + +ex:facility + a sbol:TopLevel, fac:Facility ; + sbol:displayId "facility" ; + sbol:hasNamespace ; + sbol:name "Golden Gate example facility" . + +ex:lab + a sbol:TopLevel, fac:Zone ; + sbol:displayId "lab" ; + sbol:hasNamespace ; + sbol:name "Teaching laboratory" ; + fac:facility ex:facility ; + fac:zoneKind fac:Room ; + fac:isActive true . + +ex:automation_bench + a sbol:TopLevel, fac:Zone ; + sbol:displayId "automation_bench" ; + sbol:hasNamespace ; + sbol:name "Automation bench" ; + fac:facility ex:facility ; + fac:parentZone ex:lab ; + fac:zoneKind fac:WorkArea ; + fac:isActive true . + +ex:stock_storage + a sbol:TopLevel, fac:Zone ; + sbol:displayId "stock_storage" ; + sbol:hasNamespace ; + sbol:name "Golden Gate stock storage" ; + fac:facility ex:facility ; + fac:parentZone ex:lab ; + fac:zoneKind fac:StorageZone ; + fac:isActive true . + +ex:opentrons_ot2 + a sbol:TopLevel, fac:Asset ; + sbol:displayId "opentrons_ot2" ; + sbol:hasNamespace ; + sbol:name "Opentrons OT-2 with Thermocycler Module" ; + fac:facility ex:facility ; + fac:assetKind fac:Instrument ; + fac:locatedIn ex:automation_bench ; + fac:manufacturer "Opentrons" ; + fac:model "OT-2 with Thermocycler Module Gen2" ; + fac:isActive true ; + fac:capability ex:opentrons_ot2_liquid_handling, ex:opentrons_ot2_thermal_cycling . + +ex:opentrons_ot2_liquid_handling + a sbol:Identified, fac:CapabilityOffering ; + sbol:displayId "liquid_handling" ; + fac:capabilityKind cap:LiquidHandling ; + fac:qualification fac:Plannable ; + fac:controlMode fac:ReviewedFileControl ; + fac:isActive true . + +ex:opentrons_ot2_thermal_cycling + a sbol:Identified, fac:CapabilityOffering ; + sbol:displayId "thermal_cycling" ; + fac:capabilityKind cap:ThermalCycling ; + fac:qualification fac:Plannable ; + fac:controlMode fac:ReviewedFileControl ; + fac:isActive true . + +ex:manual_workstation + a sbol:TopLevel, fac:Asset ; + sbol:displayId "manual_workstation" ; + sbol:hasNamespace ; + sbol:name "Golden Gate manual workstation" ; + fac:facility ex:facility ; + fac:assetKind fac:Workstation ; + fac:locatedIn ex:automation_bench ; + fac:isActive true ; + fac:capability ex:manual_artifact_realization, + ex:manual_material_provisioning, + ex:manual_chemical_transformation, + ex:manual_incubation, + ex:manual_antibiotic_selection, + ex:manual_plate_imaging, + ex:manual_waste_handling . + +ex:manual_artifact_realization + a sbol:Identified, fac:CapabilityOffering ; + sbol:displayId "artifact_realization" ; + fac:capabilityKind cap:ArtifactRealization ; + fac:qualification fac:Plannable ; + fac:controlMode fac:ManualControl ; + fac:isActive true . + +ex:manual_material_provisioning + a sbol:Identified, fac:CapabilityOffering ; + sbol:displayId "material_provisioning" ; + fac:capabilityKind cap:MaterialProvisioning ; + fac:qualification fac:Plannable ; + fac:controlMode fac:ManualControl ; + fac:isActive true . + +ex:manual_chemical_transformation + a sbol:Identified, fac:CapabilityOffering ; + sbol:displayId "chemical_transformation" ; + fac:capabilityKind cap:ChemicalTransformation ; + fac:qualification fac:Plannable ; + fac:controlMode fac:ManualControl ; + fac:isActive true . + +ex:manual_incubation + a sbol:Identified, fac:CapabilityOffering ; + sbol:displayId "incubation" ; + fac:capabilityKind cap:Incubation ; + fac:qualification fac:Plannable ; + fac:controlMode fac:ManualControl ; + fac:isActive true ; + fac:parameter ex:manual_incubation_duration . + +ex:manual_incubation_duration + a sbol:Identified, fac:PropertyValue ; + sbol:displayId "duration" ; + fac:propertyKind cap:Duration ; + fac:realValue "1"^^xsd:double ; + fac:unit . + +ex:manual_antibiotic_selection + a sbol:Identified, fac:CapabilityOffering ; + sbol:displayId "antibiotic_selection" ; + fac:capabilityKind cap:AntibioticSelection ; + fac:qualification fac:Plannable ; + fac:controlMode fac:ManualControl ; + fac:isActive true . + +ex:manual_plate_imaging + a sbol:Identified, fac:CapabilityOffering ; + sbol:displayId "plate_imaging" ; + fac:capabilityKind cap:PlateImaging ; + fac:qualification fac:Plannable ; + fac:controlMode fac:ManualControl ; + fac:isActive true . + +ex:manual_waste_handling + a sbol:Identified, fac:CapabilityOffering ; + sbol:displayId "waste_handling" ; + fac:capabilityKind cap:WasteHandling ; + fac:qualification fac:Plannable ; + fac:controlMode fac:ManualControl ; + fac:isActive true . + +igem:J23101 + a sbol:Component ; + sbol:displayId "J23101" ; + sbol:hasNamespace ; + sbol:type . + +igem:J23106 + a sbol:Component ; + sbol:displayId "J23106" ; + sbol:hasNamespace ; + sbol:type . + +igem:B0034 + a sbol:Component ; + sbol:displayId "B0034" ; + sbol:hasNamespace ; + sbol:type . + +igem:B0015 + a sbol:Component ; + sbol:displayId "B0015" ; + sbol:hasNamespace ; + sbol:type . + +igem:GFP + a sbol:Component ; + sbol:displayId "GFP" ; + sbol:hasNamespace ; + sbol:type . + +igem:RFP + a sbol:Component ; + sbol:displayId "RFP" ; + sbol:hasNamespace ; + sbol:type . + +materials:pSB1C3 + a sbol:Component ; + sbol:displayId "pSB1C3" ; + sbol:hasNamespace ; + sbol:type . + +materials:BsaI + a sbol:Component ; + sbol:displayId "BsaI" ; + sbol:hasNamespace ; + sbol:type . + +materials:T4_DNA_ligase + a sbol:Component ; + sbol:displayId "T4_DNA_ligase" ; + sbol:hasNamespace ; + sbol:type . + +materials:T4_DNA_ligase_buffer + a sbol:Component ; + sbol:displayId "T4_DNA_ligase_buffer" ; + sbol:hasNamespace ; + sbol:type . + +materials:nuclease_free_water + a sbol:Component ; + sbol:displayId "nuclease_free_water" ; + sbol:hasNamespace ; + sbol:type . + +materials:recovery_medium + a sbol:Component ; + sbol:displayId "recovery_medium" ; + sbol:hasNamespace ; + sbol:type . + +materials:DH5alpha + a sbol:Component ; + sbol:displayId "DH5alpha" ; + sbol:hasNamespace ; + sbol:type . + +materials:BL21 + a sbol:Component ; + sbol:displayId "BL21" ; + sbol:hasNamespace ; + sbol:type . + +materials:chloramphenicol + a sbol:Component ; + sbol:displayId "chloramphenicol" ; + sbol:hasNamespace ; + sbol:type . + +materials:reference_gfp + a sbol:Component ; + sbol:displayId "reference_gfp" ; + sbol:hasNamespace ; + sbol:type . + +designs:composite_plasmid_1 + a sbol:Component ; + sbol:displayId "composite_plasmid_1" ; + sbol:hasNamespace ; + sbol:type . + +designs:composite_plasmid_2 + a sbol:Component ; + sbol:displayId "composite_plasmid_2" ; + sbol:hasNamespace ; + sbol:type . + +designs:composite_strain_1 + a sbol:Component ; + sbol:displayId "composite_strain_1" ; + sbol:hasNamespace ; + sbol:type . + +designs:composite_strain_2 + a sbol:Component ; + sbol:displayId "composite_strain_2" ; + sbol:hasNamespace ; + sbol:type . + +designs:composite_strain_3 + a sbol:Component ; + sbol:displayId "composite_strain_3" ; + sbol:hasNamespace ; + sbol:type . + +designs:composite_strain_4 + a sbol:Component ; + sbol:displayId "composite_strain_4" ; + sbol:hasNamespace ; + sbol:type . + +designs:expression_strain + a sbol:Component ; + sbol:displayId "expression_strain" ; + sbol:hasNamespace ; + sbol:type . + +designs:reference_strain + a sbol:Component ; + sbol:displayId "reference_strain" ; + sbol:hasNamespace ; + sbol:type . + +lots:J23101_lot + a sbol:Implementation ; + sbol:displayId "J23101_lot" ; + sbol:hasNamespace ; + sbol:built igem:J23101 ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:J23106_lot + a sbol:Implementation ; + sbol:displayId "J23106_lot" ; + sbol:hasNamespace ; + sbol:built igem:J23106 ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:B0034_lot + a sbol:Implementation ; + sbol:displayId "B0034_lot" ; + sbol:hasNamespace ; + sbol:built igem:B0034 ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:B0015_lot + a sbol:Implementation ; + sbol:displayId "B0015_lot" ; + sbol:hasNamespace ; + sbol:built igem:B0015 ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:GFP_lot + a sbol:Implementation ; + sbol:displayId "GFP_lot" ; + sbol:hasNamespace ; + sbol:built igem:GFP ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:RFP_lot + a sbol:Implementation ; + sbol:displayId "RFP_lot" ; + sbol:hasNamespace ; + sbol:built igem:RFP ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:pSB1C3_lot + a sbol:Implementation ; + sbol:displayId "pSB1C3_lot" ; + sbol:hasNamespace ; + sbol:built materials:pSB1C3 ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:BsaI_lot + a sbol:Implementation ; + sbol:displayId "BsaI_lot" ; + sbol:hasNamespace ; + sbol:built materials:BsaI ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:T4_DNA_ligase_lot + a sbol:Implementation ; + sbol:displayId "T4_DNA_ligase_lot" ; + sbol:hasNamespace ; + sbol:built materials:T4_DNA_ligase ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:T4_DNA_ligase_buffer_lot + a sbol:Implementation ; + sbol:displayId "T4_DNA_ligase_buffer_lot" ; + sbol:hasNamespace ; + sbol:built materials:T4_DNA_ligase_buffer ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:nuclease_free_water_lot + a sbol:Implementation ; + sbol:displayId "nuclease_free_water_lot" ; + sbol:hasNamespace ; + sbol:built materials:nuclease_free_water ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:recovery_medium_lot + a sbol:Implementation ; + sbol:displayId "recovery_medium_lot" ; + sbol:hasNamespace ; + sbol:built materials:recovery_medium ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:DH5alpha_lot + a sbol:Implementation ; + sbol:displayId "DH5alpha_lot" ; + sbol:hasNamespace ; + sbol:built materials:DH5alpha ; + fac:materialKind inv:BacterialStock ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:BL21_lot + a sbol:Implementation ; + sbol:displayId "BL21_lot" ; + sbol:hasNamespace ; + sbol:built materials:BL21 ; + fac:materialKind inv:BacterialStock ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:chloramphenicol_lot + a sbol:Implementation ; + sbol:displayId "chloramphenicol_lot" ; + sbol:hasNamespace ; + sbol:built materials:chloramphenicol ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + +lots:reference_gfp_lot + a sbol:Implementation ; + sbol:displayId "reference_gfp_lot" ; + sbol:hasNamespace ; + sbol:built materials:reference_gfp ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . diff --git a/examples/golden-gate/lab.toml b/examples/golden-gate/lab.toml index 14d0026..b18a2e4 100644 --- a/examples/golden-gate/lab.toml +++ b/examples/golden-gate/lab.toml @@ -5,28 +5,11 @@ edition = "2026" [build] entry = "src/programs/reporter_panel.lab" -target = "opentrons-ot2" -# What this laboratory has on hand. A target build resolves every artifact -# dependency against these names: a material listed here is available to a -# reaction, and an artifact listed here is already realized and need not be -# built again. [inventory] -materials = [ - "B0015", - "B0034", - "BL21", - "BsaI", - "DH5alpha", - "GFP", - "J23101", - "J23106", - "RFP", - "T4_DNA_ligase", - "T4_DNA_ligase_buffer", - "chloramphenicol", - "nuclease_free_water", - "pSB1C3", - "recovery_medium", -] -artifacts = [] +document = "inventory/facility.ttl" + +[[execution.adapters]] +asset = "https://example.org/golden-gate/opentrons_ot2" +driver = "opentrons.ot2" +profile = "adapters/opentrons-ot2.toml" diff --git a/examples/golden-gate/src/designs/inventory.lab b/examples/golden-gate/src/designs/inventory.lab index c3bf993..3020d1d 100644 --- a/examples/golden-gate/src/designs/inventory.lab +++ b/examples/golden-gate/src/designs/inventory.lab @@ -8,6 +8,11 @@ use std.bio.designs +record Reagent + +artifact Reagent: + description?: String + J23101_sequence: DNA = dna("TTGACAGCTAGCTCAGTCCTAGGTATTATGCTAGC") J23106_sequence: DNA = dna("TTTACGGCTAGCTCAGTCCTAGGTATAGTGCTAGC") B0034_sequence: DNA = dna("AAAGAGGAGAAA") @@ -20,43 +25,63 @@ buy: // than a bare part, so the compiler knows what it is without being told // again wherever it is used. promoter J23101: + sbol_identity = "https://synbiohub.org/public/igem/J23101" sequence = J23101_sequence promoter J23106: + sbol_identity = "https://synbiohub.org/public/igem/J23106" sequence = J23106_sequence // The shared ribosome binding site and terminator. Neither has a narrower // kind here, so both are parts; a package that declares one may say more. part B0034: + sbol_identity = "https://synbiohub.org/public/igem/B0034" sequence = B0034_sequence part B0015: + sbol_identity = "https://synbiohub.org/public/igem/B0015" sequence = B0015_sequence // The fluorescent reporters, each a coding sequence. cds GFP: + sbol_identity = "https://synbiohub.org/public/igem/GFP" sequence = GFP_sequence cds RFP: + sbol_identity = "https://synbiohub.org/public/igem/RFP" sequence = RFP_sequence // Assembly backbone and the type IIS enzyme that opens it. - backbone pSB1C3 + backbone pSB1C3: + sbol_identity = "https://example.org/golden-gate/materials/pSB1C3" // BsaI cuts at 37 C; every plasmid it opens digests the same way. restriction_enzyme BsaI: + sbol_identity = "https://example.org/golden-gate/materials/BsaI" digest_temperature = 37 C digest_duration = 2 min + reagent T4_DNA_ligase: + sbol_identity = "https://example.org/golden-gate/materials/T4_DNA_ligase" + reagent T4_DNA_ligase_buffer: + sbol_identity = "https://example.org/golden-gate/materials/T4_DNA_ligase_buffer" + reagent nuclease_free_water: + sbol_identity = "https://example.org/golden-gate/materials/nuclease_free_water" + reagent recovery_medium: + sbol_identity = "https://example.org/golden-gate/materials/recovery_medium" + // Host organisms. DH5alpha is a cloning strain; BL21 is an expression strain. // Both are transformed the way competent cells are: chilled, shocked, recovered. chassis DH5alpha: + sbol_identity = "https://example.org/golden-gate/materials/DH5alpha" heat_shock_temperature = 42 C cold_incubation = 30 min recovery_temperature = 37 C recovery_duration = 60 min chassis BL21: + sbol_identity = "https://example.org/golden-gate/materials/BL21" heat_shock_temperature = 42 C cold_incubation = 30 min recovery_temperature = 37 C recovery_duration = 60 min - antibiotic chloramphenicol + antibiotic chloramphenicol: + sbol_identity = "https://example.org/golden-gate/materials/chloramphenicol" diff --git a/examples/golden-gate/src/designs/plasmids.lab b/examples/golden-gate/src/designs/plasmids.lab index 3fdade2..bac4683 100644 --- a/examples/golden-gate/src/designs/plasmids.lab +++ b/examples/golden-gate/src/designs/plasmids.lab @@ -8,8 +8,7 @@ * computes an assembled sequence rather than taking one on trust. * * The reaction chemistry in each design is scientific intent and travels with - * the artifact; where the reaction physically happens is a target profile's - * concern. + * the artifact; facility allocation determines where it physically happens. */ use std.bio.designs @@ -27,6 +26,7 @@ composite_plasmid_2_sequence: DNA = dna("TTTACGGCTAGCTCAGTCCTAGGTATAGTGCTAGCAAAG * Gate with BsaI. Accepted only if the built sequence matches the design. */ build plasmid composite_plasmid_1: + sbol_identity = "https://example.org/golden-gate/designs/composite_plasmid_1" sequence = composite_plasmid_1_sequence backbone = pSB1C3 components = [J23101, B0034, GFP, B0015] @@ -53,6 +53,7 @@ build plasmid composite_plasmid_1: * reporters. */ build plasmid composite_plasmid_2: + sbol_identity = "https://example.org/golden-gate/designs/composite_plasmid_2" sequence = composite_plasmid_2_sequence backbone = pSB1C3 components = [J23106, B0034, RFP, B0015] diff --git a/examples/golden-gate/src/designs/strains.lab b/examples/golden-gate/src/designs/strains.lab index f1e7c9c..825db81 100644 --- a/examples/golden-gate/src/designs/strains.lab +++ b/examples/golden-gate/src/designs/strains.lab @@ -18,6 +18,7 @@ use golden_gate.designs.plasmids /** The GFP reporter carried in the DH5alpha cloning strain. */ build strain composite_strain_1: + sbol_identity = "https://example.org/golden-gate/designs/composite_strain_1" chassis = DH5alpha plasmids = [composite_plasmid_1] selection = chloramphenicol @@ -35,6 +36,7 @@ build strain composite_strain_1: /** The RFP reporter carried in the DH5alpha cloning strain. */ build strain composite_strain_2: + sbol_identity = "https://example.org/golden-gate/designs/composite_strain_2" chassis = DH5alpha plasmids = [composite_plasmid_2] selection = chloramphenicol @@ -52,6 +54,7 @@ build strain composite_strain_2: /** The GFP reporter carried in the BL21 expression strain. */ build strain composite_strain_3: + sbol_identity = "https://example.org/golden-gate/designs/composite_strain_3" chassis = BL21 plasmids = [composite_plasmid_1] selection = chloramphenicol @@ -69,6 +72,7 @@ build strain composite_strain_3: /** The RFP reporter carried in the BL21 expression strain. */ build strain composite_strain_4: + sbol_identity = "https://example.org/golden-gate/designs/composite_strain_4" chassis = BL21 plasmids = [composite_plasmid_2] selection = chloramphenicol diff --git a/examples/golden-gate/targets/hamilton-star.toml b/examples/golden-gate/targets/hamilton-star.toml deleted file mode 100644 index 5ce5891..0000000 --- a/examples/golden-gate/targets/hamilton-star.toml +++ /dev/null @@ -1,114 +0,0 @@ -# The Hamilton STARlet bench this workspace can compile for. Its filename is -# its name: this is `hamilton-star`, built by `lab build --target -# hamilton-star`, and the emitted run documents execute with `lab run`. -# -# Everything here describes the laboratory, not the science: which catalog -# carriers sit on which rails, which labware sits on which carrier site, and -# where each build stage draws tips and liquid from. Another laboratory -# running the same programs supplies its own profile and changes no Lab -# source. -# -# Every field has a default matching the reference bench, so a profile -# states only what differs. This one is written out in full because it is -# the example's subject. Sites are addressed as "/" with -# 1-based site numbers; carriers and labware come from the backend's -# vendored Hamilton catalog. - -[target] -backend = "hamilton.star" - -[machine] -# A 32-rail STARlet with the standard 8-channel arm. -variant = "starlet" -channels = 8 - -# One tip carrier feeds every stage: its five sites hold one rack per -# stage/size pairing. -[deck.carriers.tips] -catalog = "tip_carrier_480" -rail = 1 - -# The 24-tube sample carrier both liquid stages draw sources from; the -# operator reloads it between the assembly and transformation runs. -[deck.carriers.sources] -catalog = "tube_carrier_24" -rail = 7 - -# The trough carrier holding the recovery-medium supply for plating. -[deck.carriers.media] -catalog = "trough_carrier_5" -rail = 8 - -[deck.carriers.plates_a] -catalog = "plate_carrier_l5" -rail = 9 - -[deck.carriers.plates_b] -catalog = "plate_carrier_l5" -rail = 15 - -[deck.source_rack] -site = "sources/1" -labware = "sample_tubes_24" -capacity = 24 - -# The reaction plate carries reactions from assembly through plating; the -# operator stages a fresh plate after moving the assembly products to the -# DNA plate. -[deck.reaction_plate] -site = "plates_a/1" -labware = "pcr_plate_96" -capacity = 96 - -[stages.assembly.small_tips] -labware = "tip_rack_50ul_filter" -slots = ["tips/1"] -capacity = 96 - -[stages.transformation.dna_plate] -labware = "pcr_plate_96" -slots = ["plates_a/2"] -capacity = 96 - -[stages.transformation.small_tips] -labware = "tip_rack_50ul_filter" -slots = ["tips/2"] -capacity = 96 - -[stages.transformation.large_tips] -labware = "tip_rack_1000ul_filter" -slots = ["tips/3"] -capacity = 96 - -# Two dilution plates. Declaring a second site raises the batch size this -# bench can hold without editing a single program. -[stages.plating.dilution_plate] -labware = "pcr_plate_96" -slots = ["plates_a/3", "plates_a/4"] -capacity = 96 - -[stages.plating.agar_plate] -labware = "pcr_plate_96" -slots = ["plates_b/1", "plates_b/2"] -capacity = 96 - -[stages.plating.media_rack] -labware = "trough_60ml" -slot = "media/1" -medium_well = "A1" - -[stages.plating.small_tips] -labware = "tip_rack_50ul_filter" -slots = ["tips/4"] -capacity = 96 - -[stages.plating.large_tips] -labware = "tip_rack_1000ul_filter" -slots = ["tips/5"] -capacity = 96 - -[run] -# Planning computes every liquid height deterministically; a bench may -# additionally opt into capacitive detection with lld = "gamma". -lld = "off" -traverse_height_mm = 245.0 diff --git a/examples/golden-gate/targets/opentrons-flex.toml b/examples/golden-gate/targets/opentrons-flex.toml deleted file mode 100644 index ba8987a..0000000 --- a/examples/golden-gate/targets/opentrons-flex.toml +++ /dev/null @@ -1,87 +0,0 @@ -# The Flex bench this workspace can compile for. Its filename is its name: -# this is `opentrons-flex`, built by `lab build --target opentrons-flex`. -# -# Everything here describes the laboratory, not the science: which modules are -# installed, what labware sits in which deck slot, where the trash bin is, and -# which pipette is on which mount. Another laboratory running the same -# programs supplies its own profile and changes no Lab source. -# -# Every field has a default matching the reference bench, so a profile states -# only what differs. This one is written out in full because it is the -# example's subject. - -[target] -backend = "opentrons.flex" - -[instruments.small] -model = "p50_single_flex" -mount = "left" - -[instruments.large] -model = "p1000_single_flex" -mount = "right" - -[deck.temperature_module] -model = "temperatureModuleV2" -slot = "C1" -labware = "opentrons_24_aluminumblock_nest_1.5ml_snapcap" -capacity = 24 - -# The Thermocycler Module GEN2 installs across slots A1 and B1, so it declares -# no slot of its own and nothing else may claim those. -[deck.thermocycler] -model = "thermocyclerModuleV2" -labware = "nest_96_wellplate_100ul_pcr_full_skirt" -capacity = 96 - -# The movable trash bin occupies its slot; tips are dropped into it by -# addressable area rather than into trash labware. -[deck.trash] -area = "movableTrashA3" - -[stages.assembly.small_tips] -labware = "opentrons_flex_96_tiprack_50ul" -slots = ["C2"] -capacity = 96 - -[stages.transformation.dna_plate] -labware = "nest_96_wellplate_100ul_pcr_full_skirt" -slots = ["C2"] -capacity = 96 - -[stages.transformation.small_tips] -labware = "opentrons_flex_96_tiprack_50ul" -slots = ["C3"] -capacity = 96 - -[stages.transformation.large_tips] -labware = "opentrons_flex_96_tiprack_1000ul" -slots = ["D2"] -capacity = 96 - -# Two dilution plates. Declaring a second slot raises the batch size this -# bench can hold without editing a single program. -[stages.plating.dilution_plate] -labware = "nest_96_wellplate_100ul_pcr_full_skirt" -slots = ["C2", "C3"] -capacity = 96 - -[stages.plating.agar_plate] -labware = "nest_96_wellplate_100ul_pcr_full_skirt" -slots = ["B2", "B3"] -capacity = 96 - -[stages.plating.media_rack] -labware = "opentrons_15_tuberack_falcon_15ml_conical" -slot = "D1" -medium_well = "A1" - -[stages.plating.small_tips] -labware = "opentrons_flex_96_tiprack_50ul" -slots = ["D2"] -capacity = 96 - -[stages.plating.large_tips] -labware = "opentrons_flex_96_tiprack_1000ul" -slots = ["D3"] -capacity = 96 diff --git a/examples/golden-gate/targets/workcell-star.toml b/examples/golden-gate/targets/workcell-star.toml deleted file mode 100644 index fb2dc55..0000000 --- a/examples/golden-gate/targets/workcell-star.toml +++ /dev/null @@ -1,19 +0,0 @@ -# A workcell bench: the Hamilton STAR does the liquid handling, an Inheco -# ODTC beside it takes the thermal programs the STAR would otherwise hand -# to the operator, and a human carries the plate between them. - -[target] -backend = "workcell" - -[[station]] -name = "star-1" -kind = "hamilton.star" -profile = "hamilton-star" - -[[station]] -name = "odtc-1" -kind = "inheco.odtc" -address = "169.254.10.40:8080" - -[transport] -between = "human" diff --git a/scripts/check-opentrons-target.sh b/scripts/check-opentrons-bundle.sh similarity index 94% rename from scripts/check-opentrons-target.sh rename to scripts/check-opentrons-bundle.sh index 7023951..858e8a4 100755 --- a/scripts/check-opentrons-target.sh +++ b/scripts/check-opentrons-bundle.sh @@ -2,7 +2,7 @@ set -eu repository=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) -project="$repository/crates/lab-compiler/src/backend/opentrons_ot2/python" +project="$repository/crates/lab-compiler/src/backend/opentrons/ot2/python" if [ "$#" -gt 1 ]; then echo "usage: $0 [generated-bundle-directory]" >&2