diff --git a/crates/lab-adapters/src/backend/mod.rs b/crates/lab-adapters/src/backend/mod.rs index 05d721ce..680d98e5 100644 --- a/crates/lab-adapters/src/backend/mod.rs +++ b/crates/lab-adapters/src/backend/mod.rs @@ -13,6 +13,7 @@ pub mod opentrons; mod procedure; mod profile; mod resources; +pub mod run_sheet; mod typst; pub use adapters::{ diff --git a/crates/lab-adapters/src/backend/run_sheet.rs b/crates/lab-adapters/src/backend/run_sheet.rs new file mode 100644 index 00000000..0bd1d2d6 --- /dev/null +++ b/crates/lab-adapters/src/backend/run_sheet.rs @@ -0,0 +1,105 @@ +//! The operator run sheet for a reviewed plan's manual steps. +//! +//! A step an instrument runs arrives with its own operator document, rendered +//! by the adapter that lowered it. A manual-control step has no adapter, so +//! this is where its instructions land: one document for the whole plan, each +//! step stating the operation it performs, the asset it uses, and the +//! parameters it runs at. + +use crate::backend::document::{Block, Column, Doc, DocMeta, code, text}; +use crate::backend::typst; + +/// Everything a manual run sheet says. +pub struct RunSheet { + /// The package whose entry workflow the plan runs. + pub package: String, + pub version: String, + /// The facility the plan allocated against, as display text. + pub facility: String, + /// The manual steps, in the order the plan performs them. + pub steps: Vec, +} + +/// One manual step of the plan. +pub struct RunStep { + /// What the operator does, e.g. "Centrifuge". + pub title: String, + /// The exact Procedure operation the step performs. + pub operation: String, + /// The asset the step uses, as display text. + pub asset: String, + /// Display-ready parameter names and values. + pub parameters: Vec<(String, String)>, +} + +/// The style sheet a rendered run sheet imports, written beside the source as +/// [`RUN_SHEET_STYLE_PATH`] so the directory typesets standalone. +pub const RUN_SHEET_STYLE: &str = typst::STYLE; + +/// The file name the style sheet is written under. +pub const RUN_SHEET_STYLE_PATH: &str = typst::STYLE_PATH; + +/// Render the run sheet as Typst source. +pub fn render_run_sheet(sheet: &RunSheet) -> String { + let mut doc = Doc::new(DocMeta::new( + "Manual protocol", + format!("Operator run sheet for {} {}", sheet.package, sheet.version), + "", + sheet.facility.clone(), + )); + doc.notice([text( + "Generated from the reviewed facility plan. Perform each step in order and \ + confirm it in the run ledger.", + )]); + for (index, step) in sheet.steps.iter().enumerate() { + doc.blocks.push(Block::Heading { + level: 1, + label: Some(format!("Step {}", index + 1)), + text: vec![text(step.title.clone())], + }); + doc.para([ + text("Perform "), + code(step.operation.clone()), + text(" using "), + code(step.asset.clone()), + text("."), + ]); + doc.table( + [Column::left("Parameter"), Column::left("Value")], + step.parameters + .iter() + .map(|(name, value)| vec![vec![code(name.clone())], vec![text(value.clone())]]), + ); + } + typst::render(&doc) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_each_step_with_its_parameters() { + let source = render_run_sheet(&RunSheet { + package: "gll".to_owned(), + version: "0.1.0".to_owned(), + facility: "Genetic Logic Lab".to_owned(), + steps: vec![RunStep { + title: "Centrifuge".to_owned(), + operation: "https://www.lab-compiler.org/ns/procedure#Centrifuge".to_owned(), + asset: "bench_workstation".to_owned(), + parameters: vec![("force".to_owned(), "4000 rcf".to_owned())], + }], + }); + assert!( + source.contains("Operator run sheet for gll 0.1.0"), + "{source}" + ); + assert!(source.contains("Step 1"), "{source}"); + assert!(source.contains("4000 rcf"), "{source}"); + assert!( + source.contains("#import \"lab-style.typ\""), + "the sheet typesets against the bundled style: {source}" + ); + } +} diff --git a/crates/lab-adapters/src/lib.rs b/crates/lab-adapters/src/lib.rs index ecd5e076..2116b5ef 100644 --- a/crates/lab-adapters/src/lib.rs +++ b/crates/lab-adapters/src/lib.rs @@ -18,7 +18,7 @@ pub use backend::{ ValidatedAdapterProfile, adapter_catalog, default_adapter_profile, lower_adapter_invocation_with_adapter, validate_adapter_profile, }; -pub use backend::{hamilton, inheco, opentrons}; +pub use backend::{hamilton, inheco, opentrons, run_sheet}; pub use invocation::{ ADAPTER_INVOCATIONS_SCHEMA_VERSION, AdapterInvocation, AdapterInvocationError, AdapterInvocationPlan, AdapterInvocationValidationError, adapter_invocation_id, diff --git a/crates/lab-cli/src/commands.rs b/crates/lab-cli/src/commands.rs index bd46e11f..12e551b7 100644 --- a/crates/lab-cli/src/commands.rs +++ b/crates/lab-cli/src/commands.rs @@ -74,6 +74,32 @@ workflow main() -> Material: ) } +/// Run the package's source generator, where its manifest declares one. +/// +/// A workspace whose Lab another frontend emits stays compiled from its source +/// of truth: the generator runs from the package root before every check, +/// plan, and build, the way a build script would. Its output surfaces only on +/// failure. +fn generate_sources(path: &Path) -> Result<()> { + let Some((root, command)) = lab_package::source_generator(path)? else { + return Ok(()); + }; + let generated = std::process::Command::new("sh") + .arg("-c") + .arg(&command) + .current_dir(&root) + .output() + .with_context(|| format!("failed to run build.generate command `{command}`"))?; + if !generated.status.success() { + bail!( + "build.generate command `{command}` failed:\n{}{}", + String::from_utf8_lossy(&generated.stdout), + String::from_utf8_lossy(&generated.stderr), + ); + } + Ok(()) +} + pub(crate) fn check(path: PathBuf, output: &Output) -> Result<()> { if path.is_file() && path.extension().is_some_and(|extension| extension == "lab") { let text = fs::read_to_string(&path) @@ -107,6 +133,7 @@ pub(crate) fn check(path: PathBuf, output: &Output) -> Result<()> { ); } + generate_sources(&path)?; let project = LabProject::discover(&path) .with_context(|| format!("failed to load project from {}", path.display()))?; validate_project_inventories(&project)?; @@ -129,7 +156,13 @@ pub(crate) fn check(path: PathBuf, output: &Output) -> Result<()> { ) } -pub(crate) fn build(path: PathBuf, out_dir: Option, output: &Output) -> Result<()> { +pub(crate) fn build( + path: PathBuf, + out_dir: Option, + program: Option, + output: &Output, +) -> Result<()> { + generate_sources(&path)?; let project = LabProject::discover(&path) .with_context(|| format!("failed to load project from {}", path.display()))?; validate_project_inventories(&project)?; @@ -193,12 +226,22 @@ pub(crate) fn build(path: PathBuf, out_dir: Option, output: &Output) -> }); } - 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 entry = program + .as_deref() + .map(|program| resolve_program(package, &compiled, program)) + .transpose()?; + let facility = if package.manifest.inventory.document.is_some() + && (entry.is_some() || package.entry_source().is_some()) + { + Some(write_facility_plan( + &project, + &compiled, + &output_root, + entry.as_deref(), + )?) + } else { + None + }; let facility_index = facility .as_ref() .map(|planned| build_facility_index(planned, &output_root)) @@ -325,17 +368,50 @@ fn build_products(modules: &[CompiledModule], program_packages: &[String]) -> Ve .collect() } -pub(crate) fn plan(path: PathBuf, out_dir: Option, output: &Output) -> Result<()> { +pub(crate) fn plan( + path: PathBuf, + out_dir: Option, + program: Option, + output: &Output, +) -> Result<()> { + generate_sources(&path)?; let project = LabProject::discover(&path) .with_context(|| format!("failed to load project from {}", path.display()))?; let compiled = project.compile()?; + let entry = program + .as_deref() + .map(|program| resolve_program(project.default_package(), &compiled, program)) + .transpose()?; + if entry.is_none() && project.default_package().entry_source().is_none() { + let programs = project + .default_package() + .program_sources() + .filter_map(|source| { + source + .relative_path + .file_stem() + .and_then(|name| name.to_str()) + }) + .collect::>(); + if !programs.is_empty() { + bail!( + "package declares no build.entry; pick a program with --program : {}", + programs.join(", ") + ); + } + } 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"), + // Each program's plan is its own reviewable artifact, so it gets its + // own directory rather than overwriting the last program planned. + None => match &program { + Some(program) => project_root.join(".lab").join("plan").join(program), + None => project_root.join(".lab").join("plan"), + }, }; - let planned = write_facility_plan(&project, &compiled, &output_root)?; + let planned = write_facility_plan(&project, &compiled, &output_root, entry.as_deref())?; let mut human = format!( "Planned {} {} against {}\n Methods selected: {}\n Requirements allocated: {}\n Adapter invocations lowered: {}\n Plan output: {}\n Planning problem: {}\n Facility solution: {}\n Allocated LAIR: {}\n Adapter invocations: {}\n Reviewed plan: {}", planned.package, @@ -355,13 +431,75 @@ pub(crate) fn plan(path: PathBuf, out_dir: Option, output: &Output) -> output.success("planned", planned, human) } +/// The entry module of one named program under `src/programs/`. +fn resolve_program( + package: &LabPackage, + compiled: &CompiledProject, + program: &str, +) -> Result { + let stem = program.replace('-', "_"); + let source = package + .program_sources() + .find(|source| { + source + .relative_path + .file_stem() + .and_then(|name| name.to_str()) + .map(|name| name.replace('-', "_")) + .as_deref() + == Some(stem.as_str()) + }) + .with_context(|| { + let programs = package + .program_sources() + .filter_map(|source| { + source + .relative_path + .file_stem() + .and_then(|name| name.to_str()) + }) + .collect::>(); + if programs.is_empty() { + format!( + "package '{}' has no programs under src/programs/", + package.manifest.package.name + ) + } else { + format!( + "no program '{program}' under src/programs/; available: {}", + programs.join(", ") + ) + } + })?; + let declares_main = compiled + .modules + .iter() + .find(|module| module.source.module == source.module) + .is_some_and(|module| { + module.module.declarations.iter().any(|declaration| { + matches!(declaration, CheckedDeclaration::Workflow { name, .. } if name == "main") + }) + }); + if !declares_main { + bail!( + "program '{program}' ({}) declares no `main` workflow", + source.module + ); + } + Ok(source.module.clone()) +} + fn write_facility_plan( project: &LabProject, compiled: &CompiledProject, output_root: &Path, + entry: Option<&str>, ) -> Result { let package = project.default_package(); - let facility = project.plan_facility_with_package_methods(compiled)?; + let facility = match entry { + Some(entry) => project.plan_facility_program(compiled, entry)?, + None => project.plan_facility_with_package_methods(compiled)?, + }; let inventory = &facility.inventory; let adapter_bindings = facility.adapter_bindings.as_ref(); let allocated = &facility.allocated; @@ -460,6 +598,11 @@ fn write_facility_plan( None }; + let mut documents = lowered.documents; + if let Some(run_sheet) = write_manual_run_sheet(package, invocations, output_root)? { + documents.push(run_sheet); + } + let bundles = lowered .manifest .routes @@ -490,10 +633,51 @@ fn write_facility_plan( execution_plan: execution_plan_path, bundles, protocols: lowered.protocols, - documents: lowered.documents, + documents, }) } +/// Typeset the operator run sheet for the plan's manual steps. +/// +/// An instrument's steps arrive with their own operator manual from the +/// adapter that lowered them; the manual steps have no adapter, so the run +/// sheet is where a person reads them. A plan with no manual step writes +/// nothing. +fn write_manual_run_sheet( + package: &lab_package::LabPackage, + invocations: &lab_adapters::AdapterInvocationPlan, + output_root: &Path, +) -> Result> { + let steps = lab_facility::manual_run_steps(invocations); + if steps.is_empty() { + return Ok(None); + } + let source = lab_adapters::run_sheet::render_run_sheet(&lab_adapters::run_sheet::RunSheet { + package: package.manifest.package.name.clone(), + version: package.manifest.package.version.clone(), + facility: invocations.allocated.facility.clone(), + steps, + }); + let directory = output_root.join("documents"); + fs::create_dir_all(&directory) + .with_context(|| format!("failed to create {}", directory.display()))?; + fs::write( + directory.join(lab_adapters::run_sheet::RUN_SHEET_STYLE_PATH), + lab_adapters::run_sheet::RUN_SHEET_STYLE, + ) + .context("failed to write the run-sheet style sheet")?; + let source_path = directory.join("manual_protocol.typ"); + fs::write(&source_path, &source) + .with_context(|| format!("failed to write {}", source_path.display()))?; + let pdf = crate::typeset::Typesetter::new() + .compile_pdf(&directory, "manual_protocol.typ") + .context("failed to typeset the manual run sheet")?; + let pdf_path = directory.join("manual_protocol.pdf"); + fs::write(&pdf_path, &pdf) + .with_context(|| format!("failed to write {}", pdf_path.display()))?; + Ok(Some(pdf_path)) +} + fn append_facility_artifacts(human: &mut String, planned: &PlanCompleted) { if !planned.bundles.is_empty() { human.push_str("\n\nAsset bundles:"); diff --git a/crates/lab-cli/src/main.rs b/crates/lab-cli/src/main.rs index f2e4db8d..03400104 100644 --- a/crates/lab-cli/src/main.rs +++ b/crates/lab-cli/src/main.rs @@ -50,6 +50,10 @@ enum Command { /// Artifact directory, relative to the project root unless absolute. #[arg(long)] out_dir: Option, + /// A program under src/programs/ to build, named by its file. The + /// build is rooted at that program's main workflow. + #[arg(long)] + program: Option, }, /// Write only the reviewed facility plan and its adapter lowerings. Plan { @@ -59,6 +63,11 @@ enum Command { /// Plan artifact directory, relative to the project root unless absolute. #[arg(long)] out_dir: Option, + /// A program under src/programs/ to plan, named by its file. The plan + /// is rooted at that program's main workflow and written under its own + /// directory. + #[arg(long)] + program: Option, }, /// Discover, validate, and render asset-bound adapter profiles. Adapters { @@ -163,8 +172,16 @@ 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 } => commands::build(path, out_dir, &output), - Command::Plan { path, out_dir } => commands::plan(path, out_dir, &output), + Command::Build { + path, + out_dir, + program, + } => commands::build(path, out_dir, program, &output), + Command::Plan { + path, + out_dir, + program, + } => commands::plan(path, out_dir, program, &output), Command::Adapters { command } => match command { AdaptersCommand::Describe { driver } => adapters::describe(driver, &output), AdaptersCommand::Default { driver, name } => adapters::default(driver, name, &output), @@ -201,7 +218,7 @@ mod tests { let cli = Cli::try_parse_from(["lab", "build", "project", "--out-dir", "dist"]).unwrap(); assert!(matches!( cli.command, - Command::Build { path, out_dir } + Command::Build { path, out_dir, .. } if path.as_path() == std::path::Path::new("project") && out_dir.as_deref() == Some(std::path::Path::new("dist")) )); @@ -212,7 +229,7 @@ mod tests { let cli = Cli::try_parse_from(["lab", "plan", "project", "--out-dir", "review"]).unwrap(); assert!(matches!( cli.command, - Command::Plan { path, out_dir } + Command::Plan { path, out_dir, .. } if path.as_path() == std::path::Path::new("project") && out_dir.as_deref() == Some(std::path::Path::new("review")) )); diff --git a/crates/lab-cli/tests/fixtures/manual-run-sheet/inventory/facility.ttl b/crates/lab-cli/tests/fixtures/manual-run-sheet/inventory/facility.ttl new file mode 100644 index 00000000..79a4dc7d --- /dev/null +++ b/crates/lab-cli/tests/fixtures/manual-run-sheet/inventory/facility.ttl @@ -0,0 +1,36 @@ +@prefix cap: . +@prefix ex: . +@prefix fac: . +@prefix sbol: . + +# A facility that is one person at one bench: every step of a build here is a +# manual-control step, so the plan's only document is the typeset run sheet. + +ex:facility a sbol:TopLevel, fac:Facility ; + sbol:displayId "facility" ; + sbol:hasNamespace ; + sbol:name "Manual media kitchen" . + +ex:room a sbol:TopLevel, fac:Zone ; + sbol:displayId "room" ; + sbol:hasNamespace ; + fac:facility ex:facility ; + fac:zoneKind fac:Room ; + fac:isActive true . + +ex:bench a sbol:TopLevel, fac:Asset ; + sbol:displayId "bench" ; + 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 . diff --git a/crates/lab-cli/tests/fixtures/manual-run-sheet/lab.toml b/crates/lab-cli/tests/fixtures/manual-run-sheet/lab.toml new file mode 100644 index 00000000..a810b5da --- /dev/null +++ b/crates/lab-cli/tests/fixtures/manual-run-sheet/lab.toml @@ -0,0 +1,10 @@ +[package] +name = "manual-media" +version = "0.1.0" +edition = "2026" + +[build] +entry = "src/programs/main.lab" + +[inventory] +document = "inventory/facility.ttl" diff --git a/crates/lab-cli/tests/fixtures/manual-run-sheet/src/programs/main.lab b/crates/lab-cli/tests/fixtures/manual-run-sheet/src/programs/main.lab new file mode 100644 index 00000000..2036fd9f --- /dev/null +++ b/crates/lab-cli/tests/fixtures/manual-run-sheet/src/programs/main.lab @@ -0,0 +1,11 @@ +use std.bio.designs +use std.bio.build + +build medium LB_broth: + components = [ + Ingredient { substance: "tryptone", concentration: 10 g/L }, + ] + +workflow main() -> Material: + product <- realize LB_broth + return product diff --git a/crates/lab-cli/tests/generated_sources.rs b/crates/lab-cli/tests/generated_sources.rs new file mode 100644 index 00000000..c4b64fdc --- /dev/null +++ b/crates/lab-cli/tests/generated_sources.rs @@ -0,0 +1,54 @@ +//! A package whose manifest declares `build.generate` compiles what its +//! generator writes, so a workspace emitted by another frontend plans with +//! the same commands a native one does. + +use std::process::Command; + +const MANIFEST: &str = "[package]\nname = \"generated\"\nversion = \"0.1.0\"\nedition = \"2026\"\n\n[build]\nentry = \"src/programs/main.lab\"\ngenerate = \"mkdir -p src/programs && cp seed.lab src/programs/main.lab\"\n"; + +const SEED: &str = "use std.bio.designs\nuse std.bio.build\n\nbuild medium LB_broth:\n components = [\n Ingredient { substance: \"tryptone\", concentration: 10 g/L },\n ]\n\nworkflow main() -> Material:\n product <- realize LB_broth\n return product\n"; + +#[test] +fn check_runs_the_source_generator_first() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("generated"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("lab.toml"), MANIFEST).unwrap(); + std::fs::write(root.join("seed.lab"), SEED).unwrap(); + + // The entry source does not exist until the generator writes it. + let output = Command::new(env!("CARGO_BIN_EXE_lab")) + .args(["check", root.to_str().unwrap()]) + .output() + .unwrap(); + assert!( + output.status.success(), + "check failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(root.join("src/programs/main.lab").is_file()); +} + +#[test] +fn a_failing_generator_names_its_command() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("generated"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + root.join("lab.toml"), + MANIFEST.replace( + "generate = \"mkdir -p src/programs && cp seed.lab src/programs/main.lab\"", + "generate = \"echo the exporter broke >&2 && false\"", + ), + ) + .unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_lab")) + .args(["check", root.to_str().unwrap()]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("build.generate"), "{stderr}"); + assert!(stderr.contains("the exporter broke"), "{stderr}"); +} diff --git a/crates/lab-cli/tests/pdf_output.rs b/crates/lab-cli/tests/pdf_output.rs index 2a562f4c..e0c2bac6 100644 --- a/crates/lab-cli/tests/pdf_output.rs +++ b/crates/lab-cli/tests/pdf_output.rs @@ -80,3 +80,61 @@ fn facility_plan_typesets_every_document_to_pdf() { "plan output lists the typeset documents: {human}" ); } + +/// A plan whose every step is manual has no adapter to render an operator +/// document, so the plan itself typesets a run sheet for the whole protocol. +#[test] +fn a_manual_only_plan_typesets_a_run_sheet() { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/manual-run-sheet"); + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("manual-media"); + copy_dir(&fixture, &project); + + let output = Command::new(env!("CARGO_BIN_EXE_lab")) + .args(["plan", project.to_str().unwrap()]) + .output() + .unwrap(); + assert!( + output.status.success(), + "facility plan failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let documents = project.join(".lab/plan/documents"); + let pdf = std::fs::read(documents.join("manual_protocol.pdf")).unwrap(); + assert!(pdf.starts_with(b"%PDF-"), "the run sheet is a PDF"); + let source = std::fs::read_to_string(documents.join("manual_protocol.typ")).unwrap(); + assert!(source.contains("Step 1"), "{source}"); + assert!(source.contains("Realize artifact"), "{source}"); + assert!( + source.contains("LB\\_broth"), + "the artifact name is escaped for Typst markup: {source}" + ); + assert!( + documents.join("lab-style.typ").is_file(), + "the style sheet is bundled beside the sheet" + ); + + let human = String::from_utf8_lossy(&output.stdout); + assert!( + human.contains("manual_protocol.pdf"), + "plan output lists the run sheet: {human}" + ); + + // Planned as a named program, the same build roots at that program's main + // and writes its plan under the program's own directory. + let output = Command::new(env!("CARGO_BIN_EXE_lab")) + .args(["plan", project.to_str().unwrap(), "--program", "main"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "program plan failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let program_pdf = project.join(".lab/plan/main/documents/manual_protocol.pdf"); + assert!( + std::fs::read(&program_pdf).unwrap().starts_with(b"%PDF-"), + "the program's run sheet is a PDF of its own" + ); +} diff --git a/crates/lab-cli/tests/project_workflow.rs b/crates/lab-cli/tests/project_workflow.rs index 152b5087..69cba331 100644 --- a/crates/lab-cli/tests/project_workflow.rs +++ b/crates/lab-cli/tests/project_workflow.rs @@ -1418,7 +1418,9 @@ fn build_emits_facility_selected_protocol_bundles_and_documents() { ); 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); + // Four adapter operator documents plus the run sheet for the plan's + // manual-control steps. + assert_eq!(facility["documents"].as_array().unwrap().len(), 5); for path in facility["protocols"] .as_array() .unwrap() @@ -1634,9 +1636,9 @@ fn the_golden_gate_facility_plan_binds_canonical_pipetting_to_the_ot2() { solution["facility"], "https://example.org/golden-gate/facility" ); - assert_eq!(solution["selections"].as_array().unwrap().len(), 8); + assert_eq!(solution["selections"].as_array().unwrap().len(), 9); let requirements = solution_requirements(&solution); - assert_eq!(requirements.len(), 42); + assert_eq!(requirements.len(), 43); assert!(requirements.iter().all(|binding| { binding["capability_kind"] != "https://sbol.io/ns/capability#LiquidHandling" })); @@ -2185,7 +2187,7 @@ fn the_extended_golden_gate_example_uses_exact_material_lots_and_the_ot2() { ); let solution = read_json(plan_dir.join("compiler/facility-solution.json")); - assert_eq!(solution["selections"].as_array().unwrap().len(), 23); + assert_eq!(solution["selections"].as_array().unwrap().len(), 27); let materials = solution_materials(&solution); let reference_input = materials .iter() @@ -2204,7 +2206,7 @@ fn the_extended_golden_gate_example_uses_exact_material_lots_and_the_ot2() { binding["symbol"] == "composite_plasmid_1" && binding["source"]["kind"] == "choice_output" })); let requirements = solution_requirements(&solution); - assert_eq!(requirements.len(), 89); + assert_eq!(requirements.len(), 93); assert!(requirements.iter().all(|binding| { binding["capability_kind"] != "https://sbol.io/ns/capability#LiquidHandling" })); diff --git a/crates/lab-compiler/src/allocation/validation.rs b/crates/lab-compiler/src/allocation/validation.rs index 1021fef9..2d10d45c 100644 --- a/crates/lab-compiler/src/allocation/validation.rs +++ b/crates/lab-compiler/src/allocation/validation.rs @@ -451,7 +451,7 @@ fn record_material_use( source: &PlanningValueSource, port_type: &PortType, ) { - if matches!(port_type, PortType::Material { .. }) { + if port_type.is_material() { *uses.entry(source.clone()).or_default() += 1; } } @@ -533,7 +533,7 @@ fn validate_allocated_material_linearity( let Some(PlanningValueSource::ChoiceOutput { choice, output }) = &input.source else { continue; }; - if matches!(input.port_type, PortType::Material { .. }) { + if input.port_type.is_material() { *uses.entry((choice.clone(), output.clone())).or_default() += 1; } } diff --git a/crates/lab-compiler/src/design/ir.rs b/crates/lab-compiler/src/design/ir.rs index f52997ee..163195e7 100644 --- a/crates/lab-compiler/src/design/ir.rs +++ b/crates/lab-compiler/src/design/ir.rs @@ -227,6 +227,61 @@ impl Verify for DesignPlasmidOp { } } +#[pliron_op( + name = "design.made_artifact", + format, + attributes = ( + made_artifact_name: StringAttr, + made_artifact_kind: StringAttr + ), + interfaces = [NOpdsInterface<0>], + results = (design: DesignType) +)] +/// Declare a facility-independent artifact of a kind with no lowering contract +/// of its own. +/// +/// A plasmid carries an assembly recipe and a strain carries transformation +/// chemistry, and each has a design op that states those facts. Everything else +/// a laboratory makes is identified by its name and its kind, and what making +/// it involves is a Method's business. +pub struct DesignMadeArtifactOp; + +impl DesignMadeArtifactOp { + pub fn new( + ctx: &mut Context, + artifact_name: impl Into, + artifact_kind: impl Into, + ) -> Self { + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![DesignType::get(ctx).into()], + vec![], + vec![], + 0, + ); + let result = Self { op }; + result.set_attr_made_artifact_name(ctx, StringAttr::new(artifact_name.into())); + result.set_attr_made_artifact_kind(ctx, StringAttr::new(artifact_kind.into())); + result + } +} + +impl Verify for DesignMadeArtifactOp { + fn verify(&self, ctx: &Context) -> Result<()> { + require_string( + self.get_attr_made_artifact_name(ctx).as_deref(), + "design.made_artifact artifact_name", + self.loc(ctx), + )?; + require_string( + self.get_attr_made_artifact_kind(ctx).as_deref(), + "design.made_artifact artifact_kind", + self.loc(ctx), + ) + } +} + #[pliron_op( name = "design.strain", format, diff --git a/crates/lab-compiler/src/method/definition.rs b/crates/lab-compiler/src/method/definition.rs index 9db1d5e2..8fc52659 100644 --- a/crates/lab-compiler/src/method/definition.rs +++ b/crates/lab-compiler/src/method/definition.rs @@ -18,10 +18,31 @@ pub enum PortType { Design, /// Physical matter in an open, method-defined state. Material { state: AbsoluteIri }, + /// Physical matter in whatever state the Intent operation asked for. + /// + /// A Method usually knows the state it produces: a transformation yields a + /// transformed culture whatever went in. Fetching something off a shelf does + /// not. What `provision` returns is competent cells, or a plasmid prep, or a + /// bottle of medium, according to what was asked for, and one signature has + /// to serve all of them because every candidate refining one Intent + /// implements one signature. + /// + /// This is a port whose state the Intent decides. It is only meaningful on + /// an output, and only where a Method output exports it, since the state is + /// read from the Intent result it corresponds to. + MaterialAsRequested, /// Non-physical information or evidence of an open semantic kind. Data { data_kind: AbsoluteIri }, } +impl PortType { + /// Whether this port carries physical matter, in a stated state or in + /// whichever one the Intent asked for. + pub fn is_material(&self) -> bool { + matches!(self, Self::Material { .. } | Self::MaterialAsRequested) + } +} + /// One named input accepted by every candidate refining the same Intent operation. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] diff --git a/crates/lab-compiler/src/method/mod.rs b/crates/lab-compiler/src/method/mod.rs index 60857f43..7c54a47d 100644 --- a/crates/lab-compiler/src/method/mod.rs +++ b/crates/lab-compiler/src/method/mod.rs @@ -22,4 +22,5 @@ pub use definition::{ }; pub use id::{IntentOperationId, LocalId, LocalIdError}; pub use registry::{MethodDefinitionError, MethodRegistry, MethodRegistryError}; +pub(crate) use standard::derived_manual_method; pub use standard::{standard_method_definitions, standard_method_registry}; diff --git a/crates/lab-compiler/src/method/refinement.rs b/crates/lab-compiler/src/method/refinement.rs index 3b33e6d6..0d2afa6c 100644 --- a/crates/lab-compiler/src/method/refinement.rs +++ b/crates/lab-compiler/src/method/refinement.rs @@ -41,8 +41,8 @@ use crate::procedure::normalization::{ }; use crate::workflow::chemistry::{ASSEMBLY_CHEMISTRY_KEYS, STRAIN_CHEMISTRY_KEYS}; use crate::workflow::ir::{ - DiluteOp, MaterialType as WorkflowMaterialType, PlateOp, ProvisionOp, RealizeOp, RecoverOp, - TransformOp, + DiluteOp, MaterialType as WorkflowMaterialType, PerformOp, PlateOp, ProvisionOp, RealizeOp, + RecoverOp, TransformOp, }; pub(crate) fn refine_method_alternatives( @@ -80,11 +80,8 @@ impl DialectConversion for MethodRefinement<'_> { } fn convert_type(&mut self, context: &mut Context, ty: TypeHandle) -> Result { - let material = { - let ty = ty.deref(context); - ty.downcast_ref::().copied() - }; - Ok(material.map_or(ty, |material| workflow_material_type(context, material))) + let state = workflow_material_state(context, ty); + Ok(state.map_or(ty, |state| procedure_material_type(context, &state))) } fn rewrite( @@ -95,7 +92,17 @@ impl DialectConversion for MethodRefinement<'_> { _operands_info: &OperandsInfo, ) -> Result<()> { let instance = intent_instance(context, operation)?; - let declared_candidates = self.registry.methods_for(&instance.operation); + // A declared verb has no registered method: the compiler derives one + // manual bench method from the operands, parameters, and result states + // the Intent already carries, and refines against that. + let derived; + let declared_candidates: &[MethodDefinition] = + if Operation::get_op::(operation, context).is_some() { + derived = vec![derived_method(context, operation)]; + &derived + } else { + self.registry.methods_for(&instance.operation) + }; if declared_candidates.is_empty() { return input_err!( operation.deref(context).loc(), @@ -131,10 +138,14 @@ impl DialectConversion for MethodRefinement<'_> { .iter() .map(|candidate| candidate.id.clone()) .collect::>(); + // A port whose state the Intent decides reads it from the result it + // corresponds to, so the Intent's own result types are resolved first. + let requested = requested_types(context, operation); let result_types = signature .outputs .iter() - .map(|output| port_type(context, &output.port_type)) + .zip(requested.iter().copied()) + .map(|(output, requested)| port_type(context, &output.port_type, requested)) .collect::>(); let choice_artifact = text_parameter(&instance.parameters, "artifact"); let choice_dependencies = text_list_parameter(&instance.parameters, "dependencies"); @@ -214,7 +225,54 @@ struct IntentInstance { parameters: BTreeMap, } +/// Build the manual bench method for one performed declared verb. +/// +/// Everything the method needs is on the operation: the states its operands +/// arrive in, the parameters it carries, how many results it yields, and the +/// capability that runs it. The result states are read from the Intent when the +/// candidate is built, so the method's outputs are requested rather than named. +fn derived_method(context: &Context, operation: Ptr) -> MethodDefinition { + let perform = Operation::get_op::(operation, context) + .expect("derived_method is only called for a workflow.perform operation"); + let operand_states = operation + .deref(context) + .operands() + .map(|value| { + material_state_iri(context, value.get_type(context)) + .expect("a performed verb takes material operands") + }) + .collect::>(); + let parameter_names = perform + .parameters(context) + .into_iter() + .map(|(name, _)| name) + .collect::>(); + let result_count = operation.deref(context).results().count(); + crate::method::derived_manual_method( + &perform.operation(context), + &perform.capability(context), + &operand_states, + ¶meter_names, + result_count, + ) +} + +/// The absolute state IRI a material value carries, on either side of the +/// Workflow-to-Procedure boundary. +fn material_state_iri(context: &Context, ty: TypeHandle) -> Option { + let handle = ty.deref(context); + if let Some(material) = handle.downcast_ref::() { + return Some(material.iri().to_owned()); + } + handle + .downcast_ref::() + .map(|material| material.state().to_owned()) +} + fn intent_operation(context: &Context, operation: Ptr) -> Option { + if let Some(perform) = Operation::get_op::(operation, context) { + return IntentOperationId::new(perform.operation(context)).ok(); + } let value = if Operation::get_op::(operation, context).is_some() { "std.bio.build.realize" } else if Operation::get_op::(operation, context).is_some() { @@ -445,6 +503,13 @@ fn intent_instance(context: &Context, operation: Ptr) -> Result(operation, context) { + // A declared verb carries its scalar parameters as the text they were + // written as. The derived method reads each one back verbatim, so a + // manual protocol shows "4000 rcf" the way the workflow said it. + for (name, value) in perform.parameters(context) { + insert_text(&mut parameters, &name, value); + } } Ok(IntentInstance { operation: semantic_operation, @@ -468,6 +533,26 @@ fn append_candidate( .deref(context) .arguments() .collect::>(); + // A task output whose state the Intent decides reads it from the choice + // result the Method exports it as. Ports that name their own state ignore + // this map entirely. + let choice_results = choice + .get_operation() + .deref(context) + .results() + .map(|value| value.get_type(context)) + .collect::>(); + let requested_by_task_output = method + .outputs + .iter() + .zip(choice_results) + .filter_map(|(output, ty)| match &output.source { + ValueReference::TaskOutput { task, output } => { + Some(((task.clone(), output.clone()), ty)) + } + ValueReference::Input { .. } => None, + }) + .collect::>(); let mut values = method .inputs .iter() @@ -490,7 +575,12 @@ fn append_candidate( let task_results = task .outputs .iter() - .map(|output| port_type(context, &output.port_type)) + .map(|output| { + let requested = requested_by_task_output + .get(&(task.id.clone(), output.name.clone())) + .copied(); + port_type(context, &output.port_type, requested) + }) .collect(); let node_id = qualified_id(choice_id, &method.id, &task.id); let output_names = task @@ -808,7 +898,7 @@ fn verify_inputs( ); } for (expected, actual) in expected.iter().zip(operands) { - if port_type_readonly(context, &expected.port_type) != actual.get_type(context) { + if port_type_readonly(context, &expected.port_type, None) != actual.get_type(context) { return input_err!( operation.deref(context).loc(), "Intent input '{}' does not match its method signature", @@ -846,7 +936,7 @@ fn verify_results( } for (expected, actual) in expected.iter().zip(actual) { let actual_type = converted_type_readonly(context, actual.get_type(context)); - if port_type_readonly(context, &expected.port_type) != actual_type { + if port_type_readonly(context, &expected.port_type, Some(actual_type)) != actual_type { return input_err!( operation.deref(context).loc(), "Intent result '{}' does not match its method signature", @@ -858,76 +948,75 @@ fn verify_results( } fn converted_type_readonly(context: &Context, ty: TypeHandle) -> TypeHandle { - let material = { - let ty = ty.deref(context); - ty.downcast_ref::().copied() - }; - material.map_or(ty, |material| { - workflow_material_type_readonly(context, material) + let state = workflow_material_state(context, ty); + state.map_or(ty, |state| { + ProcedureMaterialType::get(context, StringAttr::new(state)).into() }) } -fn workflow_material_type(context: &mut Context, material: WorkflowMaterialType) -> TypeHandle { - procedure_material_type(context, workflow_state(material)) +/// The state a Workflow material names, if the type is one. +/// +/// Refinement carries the state across the dialect boundary unchanged. Both +/// sides name a state by the same IRI, so there is nothing to translate and no +/// table that could fall out of step with the states a package declares. +fn workflow_material_state(context: &Context, ty: TypeHandle) -> Option { + ty.deref(context) + .downcast_ref::() + .map(|material| material.iri().to_owned()) } -fn workflow_material_type_readonly( - context: &Context, - material: WorkflowMaterialType, +/// The concrete type of one port. +/// +/// `requested` is the type the Intent operation's corresponding result carries, +/// which is what a port whose state the Intent decides resolves to. Every other +/// port names its own type and ignores it. +fn port_type( + context: &mut Context, + port_type: &PortType, + requested: Option, ) -> TypeHandle { - ProcedureMaterialType::get( - context, - StringAttr::new(workflow_state(material).to_owned()), - ) - .into() -} - -fn workflow_state(material: WorkflowMaterialType) -> &'static str { - match material { - WorkflowMaterialType::PlasmidProduct => { - "https://www.lab-compiler.org/ns/material-state#PlasmidProduct" - } - WorkflowMaterialType::StrainProduct => { - "https://www.lab-compiler.org/ns/material-state#StrainProduct" - } - WorkflowMaterialType::CompetentCells => { - "https://www.lab-compiler.org/ns/material-state#CompetentCells" - } - WorkflowMaterialType::TransformedCulture => { - "https://www.lab-compiler.org/ns/material-state#TransformedCulture" - } - WorkflowMaterialType::RecoveredCulture => { - "https://www.lab-compiler.org/ns/material-state#RecoveredCulture" - } - WorkflowMaterialType::DilutedCulture => { - "https://www.lab-compiler.org/ns/material-state#DilutedCulture" - } - WorkflowMaterialType::Plate => "https://www.lab-compiler.org/ns/material-state#Plate", - } -} - -fn port_type(context: &mut Context, port_type: &PortType) -> TypeHandle { match port_type { PortType::Design => DesignType::get(context).into(), PortType::Material { state } => procedure_material_type(context, state.as_str()), + PortType::MaterialAsRequested => requested + .expect("a requested port is resolved against the Intent result it corresponds to"), PortType::Data { data_kind } => { ProcedureDataType::get(context, StringAttr::new(data_kind.to_string())).into() } } } -fn port_type_readonly(context: &Context, port_type: &PortType) -> TypeHandle { +fn port_type_readonly( + context: &Context, + port_type: &PortType, + requested: Option, +) -> TypeHandle { match port_type { PortType::Design => DesignType::get(context).into(), PortType::Material { state } => { ProcedureMaterialType::get(context, StringAttr::new(state.to_string())).into() } + PortType::MaterialAsRequested => requested + .expect("a requested port is resolved against the Intent result it corresponds to"), PortType::Data { data_kind } => { ProcedureDataType::get(context, StringAttr::new(data_kind.to_string())).into() } } } +/// The type each of an Intent operation's results carries, in Procedure terms. +/// +/// This is what a port whose state the Intent decides resolves to. The results +/// were checked against the Method signature before this runs, so a port that +/// names its own state has already been agreed with the one here. +fn requested_types(context: &Context, operation: Ptr) -> Vec> { + operation + .deref(context) + .results() + .map(|value| Some(converted_type_readonly(context, value.get_type(context)))) + .collect() +} + fn procedure_material_type(context: &mut Context, state: &str) -> TypeHandle { ProcedureMaterialType::get(context, StringAttr::new(state.to_owned())).into() } diff --git a/crates/lab-compiler/src/method/registry.rs b/crates/lab-compiler/src/method/registry.rs index 4a02fd06..f0123537 100644 --- a/crates/lab-compiler/src/method/registry.rs +++ b/crates/lab-compiler/src/method/registry.rs @@ -5,8 +5,8 @@ use thiserror::Error; use crate::method::{ IntentOperationId, LocalId, MaterialSourceExpression, MethodDefinition, MethodSignature, - ParameterType, ProcedureValueExpression, ScalarType, ScalarValueExpression, TaskOutput, - ValueReference, + ParameterType, PortType, ProcedureValueExpression, ScalarType, ScalarValueExpression, + TaskOutput, ValueReference, }; /// A malformed portable method definition. @@ -14,6 +14,16 @@ use crate::method::{ pub enum MethodDefinitionError { #[error("method input `{id}` occurs more than once")] DuplicateInput { id: LocalId }, + #[error( + "method input `{id}` asks the Intent for its state, but only an output's state may be \ +decided by the Intent" + )] + RequestedInput { id: LocalId }, + #[error( + "Procedure task `{task}` output `{output}` asks the Intent for its state, but no method \ +output exports it, so there is no Intent result to read it from" + )] + UnexportedRequestedOutput { task: LocalId, output: LocalId }, #[error("method parameter `{id}` occurs more than once")] DuplicateParameter { id: LocalId }, #[error("Procedure task `{id}` occurs more than once")] @@ -118,6 +128,14 @@ impl MethodDefinition { id: input.name.clone(), }); } + // What the Intent asked for is what it asked to receive. An input + // takes whatever an earlier operation produced, so there is no + // request for it to read. + if matches!(input.port_type, PortType::MaterialAsRequested) { + return Err(MethodDefinitionError::RequestedInput { + id: input.name.clone(), + }); + } available.insert( ValueReference::Input { input: input.name.clone(), @@ -283,6 +301,27 @@ impl MethodDefinition { } } + // A requested state is read from the Intent result the Method exports + // this output as. One that is never exported has nothing to read. + for task in &self.tasks { + for output in &task.outputs { + if matches!(output.port_type, PortType::MaterialAsRequested) + && !self.outputs.iter().any(|exported| { + exported.source + == (ValueReference::TaskOutput { + task: task.id.clone(), + output: output.name.clone(), + }) + }) + { + return Err(MethodDefinitionError::UnexportedRequestedOutput { + task: task.id.clone(), + output: output.name.clone(), + }); + } + } + } + let mut output_ids = BTreeSet::new(); let mut outputs = Vec::new(); for output in &self.outputs { diff --git a/crates/lab-compiler/src/method/standard.rs b/crates/lab-compiler/src/method/standard.rs index bf934a7d..c10a43a8 100644 --- a/crates/lab-compiler/src/method/standard.rs +++ b/crates/lab-compiler/src/method/standard.rs @@ -65,7 +65,7 @@ fn artifact_realization_service() -> MethodDefinition { "realize", "RealizeArtifact", vec![input_ref("design")], - vec![output("product", material("PlasmidProduct"))], + vec![output("product", PortType::MaterialAsRequested)], select_parameters(¶meters, &["artifact", "dependencies"]), vec![material_parameter("dependencies", "dependencies")], vec![requirement( @@ -216,7 +216,7 @@ fn golden_gate_method(method_id: &str, setup: GoldenGateSetupMethod) -> MethodDe "cycle-reaction", "ThermalCycleGoldenGateReaction", vec![task_ref("setup-reaction", "reaction")], - vec![output("product", material("PlasmidProduct"))], + vec![output("product", PortType::MaterialAsRequested)], cycling_task_parameters, vec![], vec![requirement( @@ -246,7 +246,10 @@ fn material_provisioning() -> MethodDefinition { "provision", "ProvisionMaterial", vec![], - vec![output("material", material("CompetentCells"))], + // What comes off a shelf is whatever was asked for: competent + // cells, a plasmid prep, a bottle of medium. One signature serves + // all of them because the Intent names the state. + vec![output("material", PortType::MaterialAsRequested)], select_parameters(¶meters, &["item"]), vec![material_parameter("item", "item")], vec![requirement( @@ -658,6 +661,76 @@ fn manual_antibiotic_selection() -> MethodDefinition { } } +/// The manual bench method the compiler derives for a declared verb. +/// +/// A package's `action` declaration names the operation, the capability that +/// runs it, the materials it consumes and produces, and its scalar parameters. +/// That is already a complete method: one manual task takes the operands, +/// carries the parameters, requires the capability, and yields each result in +/// the state the Intent asked for. Every declared verb refines this way, so a +/// package adds vocabulary without adding a method to the registry. +pub(crate) fn derived_manual_method( + operation: &str, + capability: &str, + operand_states: &[String], + parameter_names: &[String], + result_count: usize, +) -> MethodDefinition { + let inputs = operand_states + .iter() + .enumerate() + .map(|(index, state)| input(&format!("operand{index}"), material_iri(state))) + .collect::>(); + let parameters = parameter_names + .iter() + .map(|name| parameter(name, ScalarType::Text)) + .collect::>(); + let task_inputs = (0..operand_states.len()) + .map(|index| input_ref(&format!("operand{index}"))) + .collect::>(); + let task_outputs = (0..result_count) + .map(|index| output(&format!("result{index}"), PortType::MaterialAsRequested)) + .collect::>(); + let task_parameters = parameters + .iter() + .map(|parameter| procedure_parameter(¶meter.name, parameter, None)) + .collect::>(); + let outputs = (0..result_count) + .map(|index| { + let name = format!("result{index}"); + method_output(&name, "perform", &name) + }) + .collect::>(); + MethodDefinition { + id: method(&format!("derived-{}", operation.replace('.', "-"))), + refines: intent(operation), + inputs, + parameters, + tasks: vec![task( + "perform", + &upper_camel(operation.rsplit('.').next().unwrap_or(operation)), + task_inputs, + task_outputs, + task_parameters, + vec![], + vec![requirement( + "perform", + capability, + [ControlMode::Manual], + vec![], + )], + )], + outputs, + } +} + +fn material_iri(state: &str) -> PortType { + PortType::Material { + state: AbsoluteIri::new(state.to_owned()) + .expect("a workflow material state is an absolute IRI"), + } +} + fn realization_parameters() -> Vec { [ parameter("artifact", ScalarType::Text), @@ -746,6 +819,10 @@ fn select_parameters( fn parameter_unit(name: &str) -> Option { if name.ends_with("_ul") { Some(unit("MicroL")) + } else if name.ends_with("_g_per_l") { + Some(unit("GM-PER-L")) + } else if name.ends_with("_g") { + Some(unit("GM")) } else if name.ends_with("_mm") { Some(unit("MilliM")) } else if name.ends_with("_temperature_c") { @@ -963,6 +1040,48 @@ fn upper_camel(value: &str) -> String { mod tests { use super::*; + /// A parameter carries the unit its name says it is in, so a method + /// weighing something out reaches the exact quantity that measures it. + #[test] + fn a_parameter_names_the_unit_it_is_measured_in() { + use crate::procedure::{Mass, MassConcentration}; + + let mass = parameter_unit("tryptone_g").expect("a mass parameter names grams"); + assert_eq!(mass.as_str(), "http://qudt.org/vocab/unit/GM"); + assert_eq!( + Mass::parse_grams("5") + .expect("five grams") + .as_property_value() + .unit + .as_ref() + .map(UnitIri::as_str), + Some(mass.as_str()), + "the parameter and the quantity that carries it agree on the unit" + ); + + let concentration = + parameter_unit("tryptone_g_per_l").expect("a concentration names grams per litre"); + assert_eq!( + concentration.as_str(), + "http://qudt.org/vocab/unit/GM-PER-L" + ); + assert_eq!( + MassConcentration::parse_grams_per_litre("10") + .expect("ten grams per litre") + .as_property_value() + .unit + .as_ref() + .map(UnitIri::as_str), + Some(concentration.as_str()) + ); + + // A longer suffix wins, so a concentration is not read as a mass. + assert_ne!( + parameter_unit("tryptone_g_per_l"), + parameter_unit("tryptone_g") + ); + } + #[test] fn bundled_methods_validate_and_retain_real_alternatives() { let registry = standard_method_registry(); diff --git a/crates/lab-compiler/src/procedure/ir.rs b/crates/lab-compiler/src/procedure/ir.rs index 7a4f8f3e..46c9696d 100644 --- a/crates/lab-compiler/src/procedure/ir.rs +++ b/crates/lab-compiler/src/procedure/ir.rs @@ -37,6 +37,13 @@ pub(crate) struct MaterialType { state: StringAttr, } +impl MaterialType { + /// The absolute IRI of the state this material is in. + pub(crate) fn state(&self) -> &str { + self.state.as_str() + } +} + impl Verify for MaterialType { fn verify(&self, _context: &Context) -> Result<()> { if AbsoluteIri::new(self.state.as_str()).is_err() { diff --git a/crates/lab-compiler/src/procedure/mod.rs b/crates/lab-compiler/src/procedure/mod.rs index 20bf7c02..b61e616a 100644 --- a/crates/lab-compiler/src/procedure/mod.rs +++ b/crates/lab-compiler/src/procedure/mod.rs @@ -31,7 +31,8 @@ pub use pipetting::{ }; pub use program::{ProcedureProgram, ProcedureProgramValidationError, ValidatedProcedureProgram}; pub use quantity::{ - Duration, Length, QuantityError, Temperature, TemperatureRampRate, TemperatureRange, Volume, + Duration, Length, Mass, MassConcentration, QuantityError, Temperature, TemperatureRampRate, + TemperatureRange, Volume, }; pub use thermal::{ ThermalLoad, ThermalProgramV1, ThermalProgramValidationError, ThermalStage, ThermalStep, diff --git a/crates/lab-compiler/src/procedure/quantity/concentration.rs b/crates/lab-compiler/src/procedure/quantity/concentration.rs new file mode 100644 index 00000000..0570f06d --- /dev/null +++ b/crates/lab-compiler/src/procedure/quantity/concentration.rs @@ -0,0 +1,79 @@ +use lab_capability::{ExactDecimal, PropertyValue}; +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize}; + +use super::error::QuantityError; +use super::value::{ + numeric_decimal, numeric_value, parse_decimal, quantity, require_positive, validate_unit, +}; +use crate::procedure::vocabulary::GRAM_PER_LITRE; + +/// An exact mass concentration in canonical QUDT grams per litre. +/// +/// Grams per litre is the unit a medium recipe is written in, and the unit a nucleic-acid +/// concentration converts to exactly: one nanogram per microlitre is `0.001` grams per litre. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(transparent)] +#[schemars(transparent)] +pub struct MassConcentration(PropertyValue); + +impl MassConcentration { + pub fn grams_per_litre(value: ExactDecimal) -> Result { + require_positive(&value)?; + Ok(Self(quantity(value, GRAM_PER_LITRE))) + } + + pub fn parse_grams_per_litre(value: impl AsRef) -> Result { + Self::grams_per_litre(parse_decimal(value)?) + } + + pub fn value(&self) -> &ExactDecimal { + numeric_value(&self.0) + } + + pub fn as_property_value(&self) -> &PropertyValue { + &self.0 + } +} + +impl<'de> Deserialize<'de> for MassConcentration { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = PropertyValue::deserialize(deserializer)?; + validate_unit(&value, GRAM_PER_LITRE).map_err(serde::de::Error::custom)?; + let exact = numeric_decimal(&value).map_err(serde::de::Error::custom)?; + Self::grams_per_litre(exact).map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::MassConcentration; + use crate::procedure::Mass; + + #[test] + fn concentration_is_positive_exact_and_unit_checked() { + let concentration = MassConcentration::parse_grams_per_litre("10.0").unwrap(); + assert_eq!(concentration.value().to_string(), "10"); + let json = serde_json::to_string(&concentration).unwrap(); + assert!(json.contains("http://qudt.org/vocab/unit/GM-PER-L")); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + concentration + ); + assert!(MassConcentration::parse_grams_per_litre("0").is_err()); + assert!(MassConcentration::parse_grams_per_litre("-1").is_err()); + + let grams = serde_json::to_string(&Mass::parse_grams("10").unwrap()).unwrap(); + assert!(serde_json::from_str::(&grams).is_err()); + } + + #[test] + fn nucleic_acid_concentrations_stay_exact() { + // 100 ng/uL is 0.1 g/L. + let concentration = MassConcentration::parse_grams_per_litre("0.1").unwrap(); + assert_eq!(concentration.value().to_string(), "0.1"); + } +} diff --git a/crates/lab-compiler/src/procedure/quantity/mass.rs b/crates/lab-compiler/src/procedure/quantity/mass.rs new file mode 100644 index 00000000..1ced02f9 --- /dev/null +++ b/crates/lab-compiler/src/procedure/quantity/mass.rs @@ -0,0 +1,76 @@ +use lab_capability::{ExactDecimal, PropertyValue}; +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize}; + +use super::error::QuantityError; +use super::value::{ + numeric_decimal, numeric_value, parse_decimal, quantity, require_positive, validate_unit, +}; +use crate::procedure::vocabulary::GRAM; + +/// An exact mass in canonical QUDT grams. +/// +/// Grams are the canonical unit because a mass reaches the bench through a balance, and a balance +/// reads grams. Nucleic-acid masses are exact at this scale: five nanograms is `0.000000005`, which +/// an exact decimal represents without rounding. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(transparent)] +#[schemars(transparent)] +pub struct Mass(PropertyValue); + +impl Mass { + pub fn grams(value: ExactDecimal) -> Result { + require_positive(&value)?; + Ok(Self(quantity(value, GRAM))) + } + + pub fn parse_grams(value: impl AsRef) -> Result { + Self::grams(parse_decimal(value)?) + } + + pub fn value(&self) -> &ExactDecimal { + numeric_value(&self.0) + } + + pub fn as_property_value(&self) -> &PropertyValue { + &self.0 + } +} + +impl<'de> Deserialize<'de> for Mass { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = PropertyValue::deserialize(deserializer)?; + validate_unit(&value, GRAM).map_err(serde::de::Error::custom)?; + let exact = numeric_decimal(&value).map_err(serde::de::Error::custom)?; + Self::grams(exact).map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::Mass; + use crate::procedure::Volume; + + #[test] + fn mass_is_positive_exact_and_unit_checked() { + let mass = Mass::parse_grams("5.0000").unwrap(); + assert_eq!(mass.value().to_string(), "5"); + let json = serde_json::to_string(&mass).unwrap(); + assert!(json.contains("http://qudt.org/vocab/unit/GM")); + assert_eq!(serde_json::from_str::(&json).unwrap(), mass); + assert!(Mass::parse_grams("0").is_err()); + assert!(Mass::parse_grams("-1").is_err()); + + let microlitres = serde_json::to_string(&Volume::parse_microlitres("5").unwrap()).unwrap(); + assert!(serde_json::from_str::(µlitres).is_err()); + } + + #[test] + fn nucleic_acid_masses_stay_exact() { + let mass = Mass::parse_grams("0.000000005").unwrap(); + assert_eq!(mass.value().to_string(), "0.000000005"); + } +} diff --git a/crates/lab-compiler/src/procedure/quantity/mod.rs b/crates/lab-compiler/src/procedure/quantity/mod.rs index 687fd45e..2d122abb 100644 --- a/crates/lab-compiler/src/procedure/quantity/mod.rs +++ b/crates/lab-compiler/src/procedure/quantity/mod.rs @@ -1,14 +1,18 @@ //! Exact physical quantities used by canonical Procedure contracts. +mod concentration; mod duration; mod error; mod length; +mod mass; mod temperature; mod value; mod volume; +pub use concentration::MassConcentration; pub use duration::Duration; pub use error::QuantityError; pub use length::Length; +pub use mass::Mass; pub use temperature::{Temperature, TemperatureRampRate, TemperatureRange}; pub use volume::Volume; diff --git a/crates/lab-compiler/src/procedure/vocabulary.rs b/crates/lab-compiler/src/procedure/vocabulary.rs index 7048418a..704073fc 100644 --- a/crates/lab-compiler/src/procedure/vocabulary.rs +++ b/crates/lab-compiler/src/procedure/vocabulary.rs @@ -57,6 +57,8 @@ pub const MAXIMUM_RAMP_RATE: &str = "https://sbol.io/ns/capability#MaximumRampRa pub const MAXIMUM_AIR_GAP_VOLUME: &str = "https://sbol.io/ns/capability#MaximumAirGapVolume"; pub const MICROLITRE: &str = "http://qudt.org/vocab/unit/MicroL"; +pub const GRAM: &str = "http://qudt.org/vocab/unit/GM"; +pub const GRAM_PER_LITRE: &str = "http://qudt.org/vocab/unit/GM-PER-L"; pub const DEGREE_CELSIUS: &str = "http://qudt.org/vocab/unit/DEG_C"; pub const SECOND: &str = "http://qudt.org/vocab/unit/SEC"; pub const DEGREE_CELSIUS_PER_SECOND: &str = "http://qudt.org/vocab/unit/DEG_C-PER-SEC"; diff --git a/crates/lab-compiler/src/program/lowering.rs b/crates/lab-compiler/src/program/lowering.rs index 8861dffa..ce3274a8 100644 --- a/crates/lab-compiler/src/program/lowering.rs +++ b/crates/lab-compiler/src/program/lowering.rs @@ -16,6 +16,12 @@ pub(crate) enum WorkflowActionIntent { Provision { cells: String, item: String, + /// The state the fetched material is in, named for what was fetched. + /// + /// Provisioning claimed competent cells whatever it was asked for, so + /// fetching an antibiotic produced a value the IR believed was a tube of + /// cells and nothing downstream disagreed. + state: String, }, Transform { strain: String, @@ -37,6 +43,24 @@ pub(crate) enum WorkflowActionIntent { culture: String, selection: String, }, + /// A verb a package declared, lowered generically. + /// + /// The six bespoke actions predate the `action` declaration form; everything + /// a package declares afterward arrives here. The operation, the operand + /// materials it takes, the scalar parameters it carries, and the state each + /// result arrives in are all read from the checked call and its contract. + Perform { + operation: String, + /// The capability a facility must offer to run the verb. + capability: String, + /// Result binding names paired with the state each arrives in. + results: Vec<(String, String)>, + /// The workflow bindings this action takes as material operands. + operands: Vec, + /// Scalar and measurement parameters, each as the text it was written + /// as. + parameters: Vec<(String, String)>, + }, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -52,6 +76,19 @@ struct BuildLoweringContext<'a> { bindings: &'a BTreeMap<(String, String), TypedExpression>, } +/// The state a catalogued item is in when it is fetched off a shelf. +/// +/// A chassis is bought competent, which is why transformation takes competent +/// cells rather than a chassis. Anything else arrives as stock of its own kind, +/// so a reagent is a reagent rather than a tube of cells. +fn provisioned_state(item_type: Option<&String>) -> String { + match item_type.map(String::as_str) { + Some("Chassis") => "CompetentCells".to_owned(), + Some(kind) => format!("{kind}Stock"), + None => "Stock".to_owned(), + } +} + /// The three properties a Golden Gate assembly recipe cannot be planned without. const ASSEMBLY_RECIPE_FIELDS: [&str; 3] = ["backbone", "components", "restriction_enzyme"]; @@ -72,10 +109,6 @@ fn quoted_fields(fields: &[&str]) -> String { pub enum SourceLoweringError { #[error("source module does not declare any build artifacts")] EmptyBuild, - #[error( - "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}'")] MissingField { artifact: String, @@ -141,6 +174,13 @@ incomplete; add {missing} to assemble it, or remove {present} to build it by ano pub(crate) enum BuildArtifactIntent { Plasmid(PlasmidArtifactIntent), Strain(StrainArtifactIntent), + /// An artifact of a kind with no lowering contract of its own. + /// + /// A plasmid carries an assembly recipe and a strain carries transformation + /// chemistry, and each has a lowering that reads those facts. Everything + /// else a laboratory makes is realized from its declaration: the design + /// says what it is, and a Method says how making it runs. + Made(MadeArtifactIntent), } impl BuildArtifactIntent { @@ -148,6 +188,7 @@ impl BuildArtifactIntent { match self { Self::Plasmid(intent) => &intent.name, Self::Strain(intent) => &intent.name, + Self::Made(intent) => &intent.name, } } @@ -155,6 +196,7 @@ impl BuildArtifactIntent { match self { Self::Plasmid(intent) => &intent.dependencies, Self::Strain(intent) => &intent.dependencies, + Self::Made(intent) => &intent.dependencies, } } @@ -162,10 +204,24 @@ impl BuildArtifactIntent { match self { Self::Plasmid(intent) => &intent.actions, Self::Strain(intent) => &intent.actions, + Self::Made(intent) => &intent.actions, } } } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct MadeArtifactIntent { + pub name: String, + /// The word the package declared this kind with. + pub kind: String, + /// The state the realized product arrives in, named for the type the kind + /// produces: a realized medium is a `MediumProduct` the way a realized + /// plasmid is a `PlasmidProduct`. + pub state: String, + pub dependencies: Vec, + pub actions: Vec, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct PlasmidArtifactIntent { pub name: String, @@ -274,11 +330,24 @@ pub(crate) struct StrainArtifactIntent { /// supplies them in a deterministic order. pub(crate) fn lower_build_intent( modules: &[&CheckedModule], + entry: Option<&str>, ) -> Result, SourceLoweringError> { let supplier_identities = supplier_identities(modules); let stated = inventory_properties(modules); let bindings = binding_values(modules); - let flows = realization_flows(modules, &supplier_identities)?; + let catalog_types = catalog_types(modules); + let selections = selections(modules, &supplier_identities); + // Rooted at a program, the build is what that program's `main` reaches + // through workflow calls. Everything else in scope is a library: declared, + // importable, and not built by this run. + let reachable = entry.map(|entry| reachable_workflows(modules, entry)); + let flows = realization_flows( + modules, + &supplier_identities, + &catalog_types, + &selections, + reachable.as_ref(), + )?; let context = BuildLoweringContext { flows: &flows, supplier_identities: &supplier_identities, @@ -291,19 +360,25 @@ pub(crate) fn lower_build_intent( let CheckedDeclaration::Artifact { artifact, name, + produces, properties, .. } = declaration else { continue; }; - artifacts.push(lower_artifact( + let lowered = lower_artifact( module.module.as_str(), artifact.as_str(), name, + produces, properties, &context, - )?); + ); + match lowered { + Err(SourceLoweringError::MissingRealization(_)) if reachable.is_some() => {} + other => artifacts.push(other?), + } } } if artifacts.is_empty() { @@ -312,16 +387,95 @@ pub(crate) fn lower_build_intent( Ok(artifacts) } +/// The workflows a program runs: its entry module's `main` and everything that +/// main reaches through workflow calls, keyed by module and workflow name. +fn reachable_workflows(modules: &[&CheckedModule], entry: &str) -> BTreeSet<(String, String)> { + let mut by_name: BTreeMap<&str, Vec<(&str, &[CheckedStatement])>> = BTreeMap::new(); + for module in modules { + for declaration in &module.declarations { + let CheckedDeclaration::Workflow { name, body, .. } = declaration else { + continue; + }; + by_name + .entry(name.as_str()) + .or_default() + .push((module.module.as_str(), body.as_slice())); + } + } + let mut reached = BTreeSet::new(); + let mut queue = Vec::new(); + for (module, body) in by_name.get("main").into_iter().flatten() { + if *module == entry { + reached.insert((module.to_string(), "main".to_owned())); + queue.push(*body); + } + } + while let Some(body) = queue.pop() { + for action in effects(body) { + let Some(callee) = action.operation.strip_prefix("workflow.") else { + continue; + }; + for (module, callee_body) in by_name.get(callee).into_iter().flatten() { + if reached.insert((module.to_string(), callee.to_owned())) { + queue.push(callee_body); + } + } + } + } + reached +} + +/// Every action a body performs, wherever its statement sits. +fn effects(body: &[CheckedStatement]) -> Vec<&ResolvedAction> { + let mut actions = Vec::new(); + let mut queue = vec![body]; + while let Some(body) = queue.pop() { + for statement in body { + match statement { + CheckedStatement::Effect { action, .. } => actions.push(action), + CheckedStatement::If { + body, else_body, .. + } => { + queue.push(body); + queue.push(else_body); + } + CheckedStatement::Match { cases, .. } => { + for case in cases { + queue.push(&case.body); + } + } + CheckedStatement::For { body, .. } | CheckedStatement::When { body, .. } => { + queue.push(body); + } + _ => {} + } + } + } + actions +} + fn declarations<'a>( modules: &'a [&'a CheckedModule], ) -> impl Iterator { modules.iter().flat_map(|module| module.declarations.iter()) } +fn module_declarations<'a>( + modules: &'a [&'a CheckedModule], +) -> impl Iterator { + modules.iter().flat_map(|module| { + module + .declarations + .iter() + .map(move |declaration| (&**module, declaration)) + }) +} + fn lower_artifact( module: &str, kind: &str, name: &str, + produces: &lab_language::CheckedType, properties: &[lab_language::CheckedProperty], context: &BuildLoweringContext<'_>, ) -> Result { @@ -480,12 +634,13 @@ fn lower_artifact( }, actions: flow.actions.clone(), })), - // 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(), + other => Ok(BuildArtifactIntent::Made(MadeArtifactIntent { + name: name.to_owned(), kind: other.to_owned(), - }), + state: format!("{}Product", produces.subject().display_name()), + dependencies: flow.dependencies.clone(), + actions: flow.actions.clone(), + })), } } @@ -528,6 +683,62 @@ fn inventory_properties( .collect() } +/// What each declared medium is selective for. +/// +/// A plate is a poured medium, and what it selects for is a property of that +/// medium rather than a second thing named beside it. Plating reads it from +/// there, so the antibiotic on the bench and the one in the plan are the same +/// statement. +fn selections( + modules: &[&CheckedModule], + identities: &BTreeMap, +) -> BTreeMap { + declarations(modules) + .filter_map(|declaration| { + let (name, properties) = match declaration { + CheckedDeclaration::Catalog { + name, properties, .. + } + | CheckedDeclaration::Artifact { + name, properties, .. + } => (name, properties), + _ => return None, + }; + let selection = properties + .iter() + .find(|property| property.name == "selection")?; + let CheckedExpression::Reference { path, .. } = &selection.value.value else { + return None; + }; + let referenced = path.first()?; + Some(( + name.clone(), + identities + .get(referenced) + .cloned() + .unwrap_or_else(|| referenced.clone()), + )) + }) + .collect() +} + +/// The Lab type each catalogued symbol stands for, with any state narrowing +/// removed. +/// +/// A provisioned material's state is named for the kind that was fetched, so +/// lowering has to know that `DH5alpha` is a `Chassis` and `chloramphenicol` is +/// not. +fn catalog_types(modules: &[&CheckedModule]) -> BTreeMap { + declarations(modules) + .filter_map(|declaration| match declaration { + CheckedDeclaration::Catalog { name, r#type, .. } => { + Some((name.clone(), r#type.subject().display_name())) + } + _ => None, + }) + .collect() +} + /// What each catalogued symbol calls the item a supplier lists. /// /// This deliberately ignores the separate SBOL Component IRI. Existing device @@ -549,12 +760,34 @@ fn supplier_identities(modules: &[&CheckedModule]) -> BTreeMap { fn realization_flows( modules: &[&CheckedModule], identities: &BTreeMap, + catalog_types: &BTreeMap, + selections: &BTreeMap, + reachable: Option<&BTreeSet<(String, String)>>, ) -> Result, SourceLoweringError> { + let in_scope = |module: &CheckedModule, workflow: &str| { + reachable.is_none_or(|reachable| { + reachable.contains(&(module.module.as_str().to_owned(), workflow.to_owned())) + }) + }; let mut result = BTreeMap::new(); - for declaration in declarations(modules) { - let CheckedDeclaration::Workflow { body, .. } = declaration else { + // A workflow that realizes one of its own parameters is a build written + // once for many designs: `prepare_competent_cells(chassis: Chassis)` is the + // same wash whichever strain it starts from. Its flow is a template, keyed + // by workflow name and instantiated for the design each call site passes. + let mut templates: BTreeMap = BTreeMap::new(); + for (module, declaration) in module_declarations(modules) { + let CheckedDeclaration::Workflow { + name: workflow_name, + inputs, + body, + .. + } = declaration + else { continue; }; + if !in_scope(module, workflow_name) { + continue; + } let Some(design) = realized_design(body) else { continue; }; @@ -572,6 +805,10 @@ fn realization_flows( .collect::>(); let mut dependencies = None; let mut actions = Vec::new(); + // Which declaration each fetched binding names. A plate is spread on a + // medium a workflow fetched, so what it selects for is read from the + // declaration that binding came from. + let mut provisioned: BTreeMap = BTreeMap::new(); for statement in body { let CheckedStatement::Effect { results, action } = statement else { continue; @@ -603,10 +840,13 @@ fn realization_flows( let [cells] = names.as_slice() else { return Err(invalid_results(&design, action)); }; + let declared = required_reference(action, "item", &design)?; let item = resolved_reference(action, "item", identities, &design)?; + provisioned.insert(cells.clone(), declared.clone()); actions.push(WorkflowActionIntent::Provision { cells: cells.clone(), item, + state: provisioned_state(catalog_types.get(&declared)), }); } "std.lab.plasmid.transform" => { @@ -656,14 +896,23 @@ fn realization_flows( actions.push(WorkflowActionIntent::Plate { plate: plate.clone(), culture: required_reference(action, "culture", &design)?, - selection: resolved_reference(action, "antibiotic", identities, &design)?, + selection: { + let binding = required_reference(action, "medium", &design)?; + let medium = provisioned.get(&binding).unwrap_or(&binding); + selections.get(medium).cloned().ok_or_else(|| { + SourceLoweringError::MissingField { + artifact: design.clone(), + field: "selection", + } + })? + }, }); } - operation => { - return Err(SourceLoweringError::UnsupportedWorkflowAction { - artifact: design, - operation: operation.to_owned(), - }); + // A verb the frontend accepted that is not one of the six with a + // bespoke lowering is one a package declared. Its operands, + // parameters, and result states come from the checked call. + _ => { + actions.push(perform_intent(action)?); } } } @@ -672,10 +921,53 @@ fn realization_flows( .ok_or_else(|| SourceLoweringError::InvalidDependencyFlow(design.clone()))?, actions, }; - if result.insert(design.clone(), flow).is_some() { + if let Some(parameter) = inputs.iter().position(|input| input.name == design) { + templates.insert(workflow_name.clone(), (parameter, flow)); + } else if result.insert(design.clone(), flow).is_some() { return Err(SourceLoweringError::InvalidDependencyFlow(design)); } } + + // Each call to a template realizes the design it passes, so the call site + // is where the flow gets its key. An argument that is itself a caller's + // parameter names no design yet and instantiates nothing. + for (module, declaration) in module_declarations(modules) { + let CheckedDeclaration::Workflow { + name: caller, + inputs, + body, + .. + } = declaration + else { + continue; + }; + if !in_scope(module, caller) { + continue; + } + for action in effects(body) { + let Some(workflow_name) = action.operation.strip_prefix("workflow.") else { + continue; + }; + let Some((parameter, template)) = templates.get(workflow_name) else { + continue; + }; + let design = action + .arguments + .get(*parameter) + .and_then(|argument| reference_name(&argument.value)) + .ok_or_else(|| { + SourceLoweringError::InvalidDependencyFlow(workflow_name.to_owned()) + })?; + if inputs.iter().any(|input| input.name == design) { + continue; + } + if result.insert(design.to_owned(), template.clone()).is_some() { + return Err(SourceLoweringError::InvalidDependencyFlow( + design.to_owned(), + )); + } + } + } Ok(result) } @@ -732,6 +1024,72 @@ fn dependency_names( .collect() } +/// Lower a declared verb generically from its checked call. +/// +/// A material argument is an operand the value it names flows into; a scalar or +/// measurement argument is a parameter; and each result arrives in the state its +/// type narrows to, or the product state of its kind where it narrows to none. +fn perform_intent(action: &ResolvedAction) -> Result { + let invalid = || SourceLoweringError::InvalidActionResults { + artifact: String::new(), + operation: action.operation.clone(), + }; + let capability = action.capability.clone().ok_or_else(invalid)?; + let mut operands = Vec::new(); + let mut parameters = Vec::new(); + for argument in &action.arguments { + match &argument.value.value { + CheckedExpression::Reference { path, .. } if path.len() == 1 => { + operands.push(path[0].clone()); + } + CheckedExpression::Quantity { magnitude, unit } => { + parameters.push((argument.name.clone(), format!("{magnitude} {unit}"))); + } + CheckedExpression::Integer { value } => { + parameters.push((argument.name.clone(), value.to_string())); + } + _ => return Err(invalid()), + } + } + let results = action + .results + .iter() + .map(|result| { + Ok(( + result.name.clone(), + lair_state(&result.r#type).ok_or_else(invalid)?, + )) + }) + .collect::, SourceLoweringError>>()?; + Ok(WorkflowActionIntent::Perform { + operation: action.operation.clone(), + capability, + results, + operands, + parameters, + }) +} + +/// The workflow material state a result type stands for. +/// +/// A material narrowed to a state arrives in that state; an unnarrowed one +/// arrives in its kind's product state, the way a realized plasmid is a +/// `PlasmidProduct`. +fn lair_state(ty: &lab_language::CheckedType) -> Option { + use lab_language::CheckedType; + let CheckedType::Named { name, arguments } = ty else { + return None; + }; + if name != "Material" { + return None; + } + match arguments.first()? { + CheckedType::InState { state, .. } => Some(state.clone()), + CheckedType::Named { name, .. } => Some(format!("{name}Product")), + _ => None, + } +} + fn action_argument<'a>(action: &'a ResolvedAction, name: &str) -> Option<&'a TypedExpression> { action .arguments @@ -809,7 +1167,9 @@ fn checked_symbol( identities: &BTreeMap, accepted: &[&str], ) -> Result { - let lab_language::CheckedType::Named { name, arguments } = &expression.r#type else { + // What a symbol refers to is its kind; which state that thing is in does not + // change which catalogued item the name stands for. + let lab_language::CheckedType::Named { name, arguments } = expression.r#type.subject() else { return Err(invalid_field(artifact, field)); }; if !arguments.is_empty() || !accepted.contains(&name.as_str()) { diff --git a/crates/lab-compiler/src/program/mod.rs b/crates/lab-compiler/src/program/mod.rs index b1f5b428..abaf7c81 100644 --- a/crates/lab-compiler/src/program/mod.rs +++ b/crates/lab-compiler/src/program/mod.rs @@ -21,10 +21,14 @@ use sha2::{Digest, Sha256}; use thiserror::Error; use self::lowering::{BuildArtifactIntent, WorkflowActionIntent, lower_build_intent}; -use crate::design::ir::{DesignDnaSequenceOp, DesignPlasmidOp, DesignStrainOp}; +use crate::design::ir::{ + DesignDnaSequenceOp, DesignMadeArtifactOp, DesignPlasmidOp, DesignStrainOp, +}; use crate::ir::attributes::quantity_dict; use crate::stage::{IrStage, detect_stage, initialize_stage, set_stage}; -use crate::workflow::ir::{DiluteOp, PlateOp, ProvisionOp, RealizeOp, RecoverOp, TransformOp}; +use crate::workflow::ir::{ + DiluteOp, PerformOp, PlateOp, ProvisionOp, RealizeOp, RecoverOp, TransformOp, +}; pub use self::lowering::SourceLoweringError; use crate::planning::PlanningProblemExtractionError; @@ -89,7 +93,21 @@ impl PortableLairProgram { /// policies, and workflows into their own modules. The caller supplies the /// modules in its own deterministic compilation order. pub fn lower_program(modules: &[&CheckedModule]) -> Result { - let artifacts = lower_build_intent(modules)?; + Self::lower_program_rooted(modules, None) + } + + /// Lower one program: the build its entry module's `main` reaches. + /// + /// A workspace declares more than any one run builds. Rooted at an entry, + /// the artifacts are the ones `main` reaches through workflow calls, and a + /// declaration nothing reaches is a library entry rather than an error. + /// Without a root, every declared artifact must be realized by some + /// workflow in scope. + pub fn lower_program_rooted( + modules: &[&CheckedModule], + entry: Option<&str>, + ) -> Result { + let artifacts = lower_build_intent(modules, entry)?; let mut context = Context::new(); let root = ModuleOp::new( &mut context, @@ -146,6 +164,16 @@ impl PortableLairProgram { root.append_operation(&mut context, operation.get_operation(), 0); design } + BuildArtifactIntent::Made(intent) => { + let operation = DesignMadeArtifactOp::new( + &mut context, + intent.name.clone(), + intent.kind.clone(), + ); + let design = operation.get_result_design(&context); + root.append_operation(&mut context, operation.get_operation(), 0); + design + } }; designs.insert(artifact.name().to_owned(), design); } @@ -337,29 +365,43 @@ fn append_workflow( for action in artifact.actions() { match action { WorkflowActionIntent::Realize { product } => { - let BuildArtifactIntent::Plasmid(intent) = &artifact else { - return Err(unsupported_realization(&name, "realize", "plasmid")); - }; - let operation = if let Some(recipe) = &intent.recipe { - RealizeOp::golden_gate( + let operation = match &artifact { + BuildArtifactIntent::Plasmid(intent) => match &intent.recipe { + Some(recipe) => RealizeOp::golden_gate( + context, + design, + name.clone(), + recipe.backbone.clone(), + recipe.components.clone(), + dependencies.clone(), + recipe.restriction_enzyme.clone(), + recipe.assembly_replicates, + assembly_chemistry(&recipe.chemistry, context), + ), + None => RealizeOp::new( + context, + design, + name.clone(), + dependencies.clone(), + "PlasmidProduct", + ), + }, + BuildArtifactIntent::Made(intent) => RealizeOp::new( context, design, name.clone(), - recipe.backbone.clone(), - recipe.components.clone(), dependencies.clone(), - recipe.restriction_enzyme.clone(), - recipe.assembly_replicates, - assembly_chemistry(&recipe.chemistry, context), - ) - } else { - RealizeOp::new(context, design, name.clone(), dependencies.clone()) + &intent.state, + ), + BuildArtifactIntent::Strain(_) => { + return Err(unsupported_realization(&name, "realize", "plasmid")); + } }; values.insert(product.clone(), operation.get_result_product(context)); root.append_operation(context, operation.get_operation(), 0); } - WorkflowActionIntent::Provision { cells, item } => { - let operation = ProvisionOp::competent_cells(context, item.clone()); + WorkflowActionIntent::Provision { cells, item, state } => { + let operation = ProvisionOp::new(context, item.clone(), state); values.insert(cells.clone(), operation.get_result_material(context)); root.append_operation(context, operation.get_operation(), 0); } @@ -451,6 +493,35 @@ fn append_workflow( values.insert(plate.clone(), operation.get_result_plate(context)); root.append_operation(context, operation.get_operation(), 0); } + WorkflowActionIntent::Perform { + operation, + capability, + results, + operands, + parameters, + } => { + let operand_values = operands + .iter() + .map(|operand| workflow_value(&values, operand, &name)) + .collect::, _>>()?; + let states = results + .iter() + .map(|(_, state)| state.clone()) + .collect::>(); + let performed = PerformOp::new( + context, + operation.clone(), + name.clone(), + capability.clone(), + parameters.clone(), + operand_values, + &states, + ); + for ((binding, _), result) in results.iter().zip(performed.results(context)) { + values.insert(binding.clone(), result); + } + root.append_operation(context, performed.get_operation(), 0); + } } } Ok(()) @@ -620,8 +691,14 @@ buy restriction_enzyme BsaI: sbol_identity = "https://SBOL2Build.org/BsaI" buy chassis DH5alpha: sbol_identity = "https://sbolcanvas.org/DH5alpha" + competence = competent + efficiency = 1e9 cfu/ug buy antibiotic chloramphenicol: sbol_identity = "https://example.org/golden-gate/materials/chloramphenicol" +buy medium LB_chloramphenicol_agar: + sbol_identity = "https://example.org/golden-gate/materials/LB_chloramphenicol_agar" + pouring = poured + selection = chloramphenicol buy part T4_DNA_ligase: sbol_identity = "https://example.org/golden-gate/materials/T4_DNA_ligase" buy part T4_DNA_ligase_buffer: @@ -667,14 +744,15 @@ workflow build_reporter_host( p_gfp: Material, ) -> ( strain: Material, - plate: Material, + plate: Material, ): dependencies = [p_gfp] cells <- provision DH5alpha strain, culture <- transform reporter_host from dependencies into cells culture <- recover culture for 1 h culture <- dilute culture - plate <- plate culture on chloramphenicol + agar <- provision LB_chloramphenicol_agar + plate <- plate culture on agar return strain, plate "#; @@ -713,6 +791,157 @@ workflow build_second() -> Material: return product "#; + /// Fetching something off a shelf yields what was asked for. + /// + /// Provisioning minted competent cells for every item, so an antibiotic + /// arrived in LAIR as a value the IR believed was a tube of cells and no + /// later check disagreed. One provisioning signature still serves every + /// kind, because the state is the one the Intent asked for. + /// The reason the whole state machinery exists: making LB media. + /// + /// A medium is realized from its declaration exactly as a plasmid is, and + /// arrives as a `MediumProduct` rather than being refused for not being a + /// plasmid. The Golden Gate methods do not apply, because the Intent + /// carries no assembly recipe, so the one candidate is manual realization. + #[test] + fn a_medium_realizes_without_being_a_plasmid() { + const SOURCE: &str = r#"use std.bio.designs +use std.bio.build + +build medium LB_broth: + sbol_identity = "https://example.org/media/LB_broth" + ph = 7.0 + components = [ + Ingredient { substance: "tryptone", concentration: 10 g/L }, + Ingredient { substance: "yeast extract", concentration: 5 g/L }, + Ingredient { substance: "sodium chloride", concentration: 10 g/L }, + ] + +workflow make_LB() -> Material: + product <- realize LB_broth + return product +"#; + let module = lab_language::compile_module(SOURCE).expect("module checks"); + let program = PortableLairProgram::lower(&module).expect("program lowers"); + let intent = program.ir(); + assert!( + intent.contains("design.made_artifact"), + "a medium has a design of its own: {intent}" + ); + assert!( + intent.contains("material-state#MediumProduct"), + "a realized medium is a MediumProduct: {intent}" + ); + + let refined = program + .refine_methods(crate::method::standard_method_registry()) + .expect("the manual realization method refines it") + .ir(); + assert!( + refined.contains("method#manual-artifact-realization"), + "manual realization applies: {refined}" + ); + assert!( + !refined.contains("golden-gate"), + "an Intent with no assembly recipe is not a Golden Gate candidate: {refined}" + ); + } + + /// A package can declare a verb the compiler has never seen, and a workflow + /// that performs it refines to a manual bench method derived from the + /// declaration alone: the operand it consumes, the parameter it carries, and + /// the state its result arrives in. + #[test] + fn a_declared_verb_refines_to_a_derived_manual_method() { + const SOURCE: &str = r#"use std.bio.designs +use std.bio.build + +build medium LB_broth: + components = [ + Ingredient { substance: "tryptone", concentration: 10 g/L }, + ] + +action degas for -> degassed: + medium: take Material + duration: Quantity + degassed: Material + requires StaticIncubation + +workflow make_LB() -> Material: + broth <- realize LB_broth + clear <- degas broth for 5 min + return clear +"#; + let module = lab_language::compile_module(SOURCE).expect("module checks"); + let portable = PortableLairProgram::lower(&module).expect("program lowers"); + let intent = portable.ir(); + assert!( + intent.contains("workflow.perform"), + "the declared verb lowers to a perform Intent: {intent}" + ); + + let refined = portable + .refine_standard_methods() + .expect("the derived manual method refines the declared verb"); + let problem = refined.planning_problem().expect("problem projects"); + let choice = problem + .choices + .iter() + .find(|choice| choice.source_operation.as_str() == "standalone.degas") + .expect("the declared verb becomes a planning choice"); + assert_eq!(choice.candidates.len(), 1); + assert_eq!( + choice.candidates[0].method.as_str(), + "https://www.lab-compiler.org/ns/method#derived-standalone-degas" + ); + } + + #[test] + fn provisioning_yields_the_state_of_the_thing_fetched() { + const SOURCE: &str = r#"use std.bio.designs +use std.bio.build +use std.lab.plasmid + +buy chassis DH5alpha: + competence = competent + efficiency = 1e9 cfu/ug +buy antibiotic chloramphenicol: + sbol_identity = "https://example.org/cam" + +build plasmid p: + sequence = dna("ACGTACGT") + +workflow w() -> ( + product: Material, + cells: Material, + drug: Material, +): + dependencies = [] + product <- realize p from dependencies + cells <- provision DH5alpha + drug <- provision chloramphenicol + return product, cells, drug +"#; + let module = lab_language::compile_module(SOURCE).expect("module checks"); + let ir = PortableLairProgram::lower(&module) + .expect("program lowers") + .ir(); + + assert!( + ir.contains("material-state#CompetentCells"), + "a chassis is bought competent: {ir}" + ); + assert!( + ir.contains("material-state#AntibioticStock"), + "an antibiotic is an antibiotic, not a tube of cells: {ir}" + ); + assert_eq!( + ir.matches("material-state#CompetentCells").count(), + 1, + "only the chassis is competent cells: {ir}" + ); + } + #[test] fn lowers_an_artifact_and_its_workflow_from_separate_modules() { let designs = compile_module_in_environment( @@ -731,6 +960,18 @@ workflow build_second() -> Material: PortableLairProgram::lower_program(&[&designs, &workflows]).expect("program lowers"); let split = program.ir(); + // What comes off a shelf is what was asked for. Fetching the chassis + // yields competent cells; fetching the antibiotic used to yield them + // too, which was the IR believing an antibiotic was a tube of cells. + assert!( + split.contains("workflow.material <\"https://www.lab-compiler.org/ns/material-state#CompetentCells\">"), + "a provisioned chassis is competent cells: {split}" + ); + assert!( + !split.contains("#AntibioticStock"), + "this program provisions no antibiotic, so no such state appears" + ); + assert_eq!(split.matches(" = design.dna_sequence ").count(), 1); assert!(split.contains("sequence_name: builtin.string \"gfp_sequence\"")); assert!(split.contains("elements: builtin.string \"ACGT\"")); @@ -830,6 +1071,136 @@ workflow main() -> Material: ); } + /// Making competent cells: the whole point of the declared-verb machinery. + /// + /// Growing cells up to a target optical density, chilling them, spinning + /// them into a pellet, and washing them into cold buffer are four verbs the + /// compiler has no Rust for. They arrive from `std.lab.competence` as + /// declarations, lower to perform Intents, and refine to derived manual + /// methods, so the protocol reaches a planning problem end to end. + #[test] + fn a_competent_cell_protocol_lowers_and_refines() { + const SOURCE: &str = r#"use std.bio.designs +use std.bio.build +use std.lab.plasmid +use std.lab.competence + +buy buffer cold_cacl2: + concentration = 100 mM + +build chassis DH5a_competent: + heat_shock_temperature = 42 C + +workflow prepare() -> Material: + cells <- realize DH5a_competent + wash <- provision cold_cacl2 + culture <- grow cells at 37 C to 0.40 OD600 + chilled <- chill culture for 10 min + pellet <- centrifuge chilled at 4000 rcf for 10 min + competent <- resuspend pellet in wash + return competent +"#; + let module = lab_language::compile_module(SOURCE).expect("the protocol checks"); + let portable = PortableLairProgram::lower(&module).expect("the protocol lowers"); + let intent = portable.ir(); + assert_eq!( + intent.matches("workflow.perform").count(), + 4, + "grow, chill, centrifuge, and resuspend each lower to a perform: {intent}" + ); + + let refined = portable + .refine_standard_methods() + .expect("every declared verb refines to a derived manual method"); + let problem = refined.planning_problem().expect("the problem projects"); + for verb in [ + "std.lab.competence.grow", + "std.lab.competence.chill", + "std.lab.competence.centrifuge", + "std.lab.competence.resuspend", + ] { + let choice = problem + .choices + .iter() + .find(|choice| choice.source_operation.as_str() == verb) + .unwrap_or_else(|| panic!("'{verb}' becomes a planning choice")); + assert_eq!( + choice.candidates.len(), + 1, + "'{verb}' has one derived method" + ); + } + } + + /// A protocol is written once and run for many designs: a workflow that + /// realizes one of its own parameters is a template, and each call site's + /// argument decides which declared artifact its flow builds. + #[test] + fn a_workflow_realizing_its_parameter_serves_every_design_its_callers_pass() { + const SOURCE: &str = r#"use std.bio.designs +use std.bio.build +use std.lab.plasmid +use std.lab.competence + +buy buffer cold_cacl2: + concentration = 100 mM + +build chassis DH5alpha: + heat_shock_temperature = 42 C + +build chassis Top10: + heat_shock_temperature = 42 C + +workflow prepare_competent_cells(chassis: Chassis) -> Material: + cells <- realize chassis + wash <- provision cold_cacl2 + culture <- grow cells at 37 C to 0.40 OD600 + chilled <- chill culture for 10 min + pellet <- centrifuge chilled at 4000 rcf for 10 min + ready <- resuspend pellet in wash + return ready + +workflow main() -> ( + a: Material, + b: Material, +): + a <- prepare_competent_cells DH5alpha + b <- prepare_competent_cells Top10 + return a, b +"#; + let module = lab_language::compile_module(SOURCE).expect("the program checks"); + let portable = PortableLairProgram::lower(&module).expect("both instantiations lower"); + let intent = portable.ir(); + assert_eq!( + intent.matches("design.made_artifact").count(), + 2, + "each chassis keeps its own design: {intent}" + ); + + let problem = portable + .refine_standard_methods() + .expect("both instantiations refine") + .planning_problem() + .expect("the problem projects"); + let realizations = problem + .choices + .iter() + .filter(|choice| choice.source_operation.as_str() == "std.bio.build.realize") + .count(); + assert_eq!(realizations, 2, "one realization per design the calls pass"); + assert_eq!( + problem + .choices + .iter() + .filter(|choice| { + choice.source_operation.as_str() == "std.lab.competence.centrifuge" + }) + .count(), + 2, + "the one written protocol runs once per instantiation" + ); + } + #[test] fn standard_methods_replace_every_workflow_op_with_verified_alternatives() { let checked = compile_module( diff --git a/crates/lab-compiler/src/workflow/ir.rs b/crates/lab-compiler/src/workflow/ir.rs index a0af77cb..c5491528 100644 --- a/crates/lab-compiler/src/workflow/ir.rs +++ b/crates/lab-compiler/src/workflow/ir.rs @@ -4,7 +4,9 @@ //! and build policy. They deliberately describe neither a concrete laboratory //! procedure nor robot resources; protocol selection owns that transition. +use lab_capability::AbsoluteIri; use pliron::builtin::attributes::{DictAttr, IntegerAttr, StringAttr, VecAttr}; +use pliron::builtin::op_interfaces::{AtLeastNOpdsInterface, AtLeastNResultsInterface}; use pliron::common_traits::Verify; use pliron::context::Context; use pliron::derive::{pliron_op, pliron_type}; @@ -12,9 +14,9 @@ use pliron::location::Location; use pliron::op::Op; use pliron::operation::Operation; use pliron::result::Result; -use pliron::r#type::{Type, TypeHandle, Typed}; +use pliron::r#type::{TypeHandle, Typed}; use pliron::value::Value; -use pliron::verify_err; +use pliron::{verify_err, verify_err_noloc}; use crate::design::ir::DesignType; use crate::ir::attributes::{ @@ -23,22 +25,44 @@ use crate::ir::attributes::{ }; use crate::workflow::chemistry::{ASSEMBLY_CHEMISTRY_KEYS, STRAIN_CHEMISTRY_KEYS}; -/// Abstract material states visible in a source-level build workflow. -#[pliron_type(name = "workflow.material", format, verifier = "succ")] -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum MaterialType { - PlasmidProduct, - StrainProduct, - CompetentCells, - TransformedCulture, - RecoveredCulture, - DilutedCulture, - Plate, +/// The namespace the states this compiler mints are named in. +pub const STATE_NS: &str = "https://www.lab-compiler.org/ns/material-state#"; + +/// One abstract material state visible in a source-level build workflow. +/// +/// The state is an absolute IRI rather than one of a fixed set, because the set +/// was never fixed: `method::standard` already names `AssemblyReaction`, +/// `TransformationMixture`, and `RecoveryMixture`, none of which a closed +/// enumeration here admitted. A package that declares a facet names states this +/// dialect has not heard of, and that is the point. +#[pliron_type( + name = "workflow.material", + generate_get = true, + format = "`<` $state `>`" +)] +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct MaterialType { + state: StringAttr, } impl MaterialType { - pub fn get(self, ctx: &Context) -> TypeHandle { - Self::instantiate(self, ctx).into() + /// The handle for a state this compiler mints, given its bare name. + pub fn state(ctx: &Context, name: &str) -> TypeHandle { + Self::get(ctx, StringAttr::new(format!("{STATE_NS}{name}"))).into() + } + + /// The absolute IRI this state is named by. + pub fn iri(&self) -> &str { + self.state.as_str() + } +} + +impl Verify for MaterialType { + fn verify(&self, _context: &Context) -> Result<()> { + if AbsoluteIri::new(self.state.as_str()).is_err() { + return verify_err_noloc!("workflow.material state must be an absolute IRI"); + } + Ok(()) } } @@ -62,17 +86,22 @@ pub struct RealizeOp; impl RealizeOp { /// Construct an artifact-realization Intent without assuming a laboratory method. + /// + /// The product's state is named for what is being realized. A plasmid + /// arrives as `PlasmidProduct` and a medium as `MediumProduct`, and the + /// caller says which because the caller read the declaration. pub fn new( ctx: &mut Context, design: Value, artifact_name: impl Into, dependencies: Vec, + state: &str, ) -> Self { let result = Self { op: Operation::new( ctx, Self::get_concrete_op_info(), - vec![MaterialType::PlasmidProduct.get(ctx)], + vec![MaterialType::state(ctx, state)], vec![design], vec![], 0, @@ -96,7 +125,7 @@ impl RealizeOp { assembly_replicates: u8, chemistry: DictAttr, ) -> Self { - let result = Self::new(ctx, design, artifact_name, dependencies); + let result = Self::new(ctx, design, artifact_name, dependencies, "PlasmidProduct"); result.set_attr_realize_backbone(ctx, StringAttr::new(backbone.into())); result.set_attr_realize_components(ctx, string_vec(components)); result.set_attr_realize_restriction_enzyme(ctx, StringAttr::new(restriction_enzyme.into())); @@ -161,9 +190,20 @@ impl Verify for RealizeOp { self.loc(ctx), )?; } - require_material( + // Which state the product arrives in is named for what is realized, so + // the verifier checks that it is a material rather than which one. A + // Golden Gate recipe still implies a plasmid, and says so. + if present == recipe_attributes.len() { + return require_material( + self.get_result_product(ctx), + "PlasmidProduct", + self.loc(ctx), + ctx, + ); + } + require_any_material( self.get_result_product(ctx), - MaterialType::PlasmidProduct, + "workflow.realize product", self.loc(ctx), ctx, ) @@ -176,16 +216,22 @@ impl Verify for RealizeOp { attributes = (provision_item: StringAttr), results = (material: MaterialType) )] -/// Request an inventory item as abstract competent-cell material. +/// Request an inventory item as the material its kind arrives in. pub struct ProvisionOp; impl ProvisionOp { - pub fn competent_cells(ctx: &mut Context, item: impl Into) -> Self { + /// Fetch an item off the shelf in the state its kind arrives in. + /// + /// The state is stated by the caller rather than assumed here, so fetching + /// an antibiotic yields an antibiotic. The provisioning Method reads it back + /// from this result, which is how one signature serves every kind of thing a + /// shelf holds. + pub fn new(ctx: &mut Context, item: impl Into, state: &str) -> Self { let result = Self { op: Operation::new( ctx, Self::get_concrete_op_info(), - vec![MaterialType::CompetentCells.get(ctx)], + vec![MaterialType::state(ctx, state)], vec![], vec![], 0, @@ -203,9 +249,11 @@ impl Verify for ProvisionOp { "provision_item", self.loc(ctx), )?; - require_material( + // Which state a fetched item is in depends on what was fetched, so the + // verifier checks that it is a material rather than which one. + require_any_material( self.get_result_material(ctx), - MaterialType::CompetentCells, + "workflow.provision result", self.loc(ctx), ctx, ) @@ -250,8 +298,8 @@ impl TransformOp { ctx, Self::get_concrete_op_info(), vec![ - MaterialType::StrainProduct.get(ctx), - MaterialType::TransformedCulture.get(ctx), + MaterialType::state(ctx, "StrainProduct"), + MaterialType::state(ctx, "TransformedCulture"), ], vec![design, cells], vec![], @@ -304,19 +352,19 @@ impl Verify for TransformOp { )?; require_material( self.get_operand_cells(ctx), - MaterialType::CompetentCells, + "CompetentCells", self.loc(ctx), ctx, )?; require_material( self.get_result_strain(ctx), - MaterialType::StrainProduct, + "StrainProduct", self.loc(ctx), ctx, )?; require_material( self.get_result_culture(ctx), - MaterialType::TransformedCulture, + "TransformedCulture", self.loc(ctx), ctx, ) @@ -360,7 +408,7 @@ impl RecoverOp { op: Operation::new( ctx, Self::get_concrete_op_info(), - vec![MaterialType::RecoveredCulture.get(ctx)], + vec![MaterialType::state(ctx, "RecoveredCulture")], vec![culture], vec![], 0, @@ -421,13 +469,13 @@ impl Verify for RecoverOp { } require_material( self.get_operand_culture(ctx), - MaterialType::TransformedCulture, + "TransformedCulture", self.loc(ctx), ctx, )?; require_material( self.get_result_recovered(ctx), - MaterialType::RecoveredCulture, + "RecoveredCulture", self.loc(ctx), ctx, ) @@ -467,7 +515,7 @@ impl DiluteOp { op: Operation::new( ctx, Self::get_concrete_op_info(), - vec![MaterialType::DilutedCulture.get(ctx)], + vec![MaterialType::state(ctx, "DilutedCulture")], vec![culture], vec![], 0, @@ -522,13 +570,13 @@ impl Verify for DiluteOp { )?; require_material( self.get_operand_culture(ctx), - MaterialType::RecoveredCulture, + "RecoveredCulture", self.loc(ctx), ctx, )?; require_material( self.get_result_diluted(ctx), - MaterialType::DilutedCulture, + "DilutedCulture", self.loc(ctx), ctx, ) @@ -572,7 +620,7 @@ impl PlateOp { op: Operation::new( ctx, Self::get_concrete_op_info(), - vec![MaterialType::Plate.get(ctx)], + vec![MaterialType::state(ctx, "Plate")], vec![culture], vec![], 0, @@ -634,19 +682,173 @@ impl Verify for PlateOp { } require_material( self.get_operand_culture(ctx), - MaterialType::DilutedCulture, + "DilutedCulture", self.loc(ctx), ctx, )?; - require_material( - self.get_result_plate(ctx), - MaterialType::Plate, + require_material(self.get_result_plate(ctx), "Plate", self.loc(ctx), ctx) + } +} + +/// A durable laboratory action whose shape a declaration supplies. +/// +/// The six operations with a bespoke op each predate the open `MaterialType` +/// and the `action` declaration form. A verb a package declares lowers here: +/// the operation it refines, the artifact it belongs to, and its scalar +/// parameters travel as data, and its material operands and results are the +/// values and states the contract named. +#[pliron_op( + name = "workflow.perform", + format, + attributes = ( + perform_operation: StringAttr, + perform_artifact: StringAttr, + perform_capability: StringAttr, + perform_parameter_names: VecAttr, + perform_parameters: DictAttr + ), + interfaces = [AtLeastNOpdsInterface<0>, AtLeastNResultsInterface<0>] +)] +pub struct PerformOp; + +impl PerformOp { + /// One performed action: the operation it refines, the artifact it is part + /// of, the capability that runs it, its scalar parameters, its material + /// operands, and the state each of its results arrives in. + #[allow(clippy::too_many_arguments)] + pub fn new( + ctx: &mut Context, + operation: impl Into, + artifact: impl Into, + capability: impl Into, + parameters: Vec<(String, String)>, + operands: Vec, + result_states: &[String], + ) -> Self { + let results = result_states + .iter() + .map(|state| MaterialType::state(ctx, state)) + .collect::>(); + let result = Self { + op: Operation::new( + ctx, + Self::get_concrete_op_info(), + results, + operands, + vec![], + 0, + ), + }; + let names = parameters + .iter() + .map(|(name, _)| name.clone()) + .collect::>(); + result.set_attr_perform_operation(ctx, StringAttr::new(operation.into())); + result.set_attr_perform_artifact(ctx, StringAttr::new(artifact.into())); + result.set_attr_perform_capability(ctx, StringAttr::new(capability.into())); + result.set_attr_perform_parameter_names(ctx, string_vec(names)); + result.set_attr_perform_parameters(ctx, parameter_dict(parameters)); + result + } + + /// The capability a facility must offer to run this action. + pub fn capability(&self, ctx: &Context) -> String { + self.get_attr_perform_capability(ctx) + .expect("a verified workflow.perform carries its capability") + .as_str() + .to_owned() + } + + /// The Intent operation this action refines. + pub fn operation(&self, ctx: &Context) -> String { + self.get_attr_perform_operation(ctx) + .expect("a verified workflow.perform carries its operation") + .as_str() + .to_owned() + } + + /// This action's result materials, in the order they were declared. + pub fn results(&self, ctx: &Context) -> Vec { + self.get_operation().deref(ctx).results().collect() + } + + /// This action's scalar parameters, each the text its value was written as. + pub fn parameters(&self, ctx: &Context) -> Vec<(String, String)> { + use pliron::identifier::Identifier; + let (Some(names), Some(dict)) = ( + self.get_attr_perform_parameter_names(ctx), + self.get_attr_perform_parameters(ctx), + ) else { + return Vec::new(); + }; + names + .0 + .iter() + .filter_map(|name| { + let name = name.downcast_ref::()?.as_str(); + let key = Identifier::try_from(name).ok()?; + let value = dict.lookup(&key)?.downcast_ref::()?.as_str(); + Some((name.to_owned(), value.to_owned())) + }) + .collect() + } +} + +impl Verify for PerformOp { + fn verify(&self, ctx: &Context) -> Result<()> { + require_string( + self.get_attr_perform_operation(ctx).as_deref(), + "perform_operation", self.loc(ctx), - ctx, - ) + )?; + require_string( + self.get_attr_perform_artifact(ctx).as_deref(), + "perform_artifact", + self.loc(ctx), + )?; + require_string( + self.get_attr_perform_capability(ctx).as_deref(), + "perform_capability", + self.loc(ctx), + )?; + if self.get_attr_perform_parameters(ctx).is_none() { + return verify_err!( + self.loc(ctx), + "workflow.perform requires perform_parameters" + ); + } + let operation = self.get_operation(); + let results = operation.deref(ctx).results().collect::>(); + for (index, result) in results.into_iter().enumerate() { + require_any_material( + result, + &format!("workflow.perform result {index}"), + self.loc(ctx), + ctx, + )?; + } + Ok(()) } } +/// A parameter dictionary keyed by name, each value the text a magnitude and +/// unit are written as. A scalar rides the same way, as its own text. +fn parameter_dict(parameters: Vec<(String, String)>) -> DictAttr { + use pliron::identifier::Identifier; + DictAttr::new( + parameters + .into_iter() + .map(|(name, value)| { + ( + Identifier::try_from(name.as_str()) + .expect("a parameter name is a checked identifier"), + StringAttr::new(value).into(), + ) + }) + .collect(), + ) +} + fn require_count( value: Option<&IntegerAttr>, name: &str, @@ -663,21 +865,33 @@ fn require_count( Ok(()) } -fn require_material( - value: Value, - expected: MaterialType, - location: Location, - ctx: &Context, -) -> Result<()> { +/// Verify that a value is a material, whatever state it is in. +fn require_any_material(value: Value, what: &str, location: Location, ctx: &Context) -> Result<()> { + let handle = value.get_type(ctx); + let ty = handle.deref(ctx); + if ty.downcast_ref::().is_none() { + return verify_err!(location, "{what} must be a Workflow material"); + } + Ok(()) +} + +/// Verify that a value is a material in the named state. +/// +/// The state is written as a bare name and compared as the IRI it stands for, +/// so a verifier reads as the state a person would say out loud while the +/// comparison stays exact. +fn require_material(value: Value, expected: &str, location: Location, ctx: &Context) -> Result<()> { let handle = value.get_type(ctx); let ty = handle.deref(ctx); let Some(actual) = ty.downcast_ref::() else { - return verify_err!(location, "expected Workflow material type {expected:?}"); + return verify_err!(location, "expected Workflow material state '{expected}'"); }; - if *actual != expected { + let expected_iri = format!("{STATE_NS}{expected}"); + if actual.iri() != expected_iri { return verify_err!( location, - "expected Workflow material type {expected:?}, found {actual:?}" + "expected Workflow material state '{expected_iri}', found '{}'", + actual.iri() ); } Ok(()) diff --git a/crates/lab-facility/src/execution.rs b/crates/lab-facility/src/execution.rs index f301d8e6..6b05a7fc 100644 --- a/crates/lab-facility/src/execution.rs +++ b/crates/lab-facility/src/execution.rs @@ -599,6 +599,94 @@ fn semantic_value(value: &lab_capability::ScalarValue) -> ExecutionParameterValu } } +/// The manual steps of a lowered program, in the order the plan performs them. +/// +/// A step an instrument runs arrives with its own operator document, rendered +/// by the adapter that lowered it. These are the ones a person performs, +/// projected as display-ready text for the run sheet the CLI typesets. +pub fn manual_run_steps( + invocations: &AdapterInvocationPlan, +) -> Vec { + let mut steps = Vec::new(); + for method in &invocations.allocated.methods { + for task in &method.tasks { + for binding in &task.requirements { + if binding.control_mode != ControlMode::Manual.iri() { + continue; + } + steps.push(lab_adapters::run_sheet::RunStep { + title: spaced_words(local_fragment(task.operation.as_str())), + operation: task.operation.to_string(), + asset: local_fragment(&binding.asset).to_owned(), + parameters: task + .parameters + .iter() + .map(|parameter| { + ( + local_fragment(parameter.id.as_str()).to_owned(), + display_procedure_value(¶meter.value), + ) + }) + .collect(), + }); + } + } + } + steps +} + +/// The local name at the end of an IRI or a `::`-qualified identifier. +fn local_fragment(value: &str) -> &str { + let value = value.rsplit("::").next().unwrap_or(value); + value + .rsplit(['#', '/']) + .next() + .filter(|fragment| !fragment.is_empty()) + .unwrap_or(value) +} + +/// `RealizeArtifact` read aloud: "Realize artifact". +fn spaced_words(value: &str) -> String { + let mut words = String::new(); + for (index, character) in value.chars().enumerate() { + if character.is_uppercase() && index > 0 { + words.push(' '); + words.extend(character.to_lowercase()); + } else { + words.push(character); + } + } + words +} + +/// A parameter value the way an operator reads it: text unquoted, a unit by +/// its name, and an empty list said in words. +fn display_procedure_value(value: &ProcedureValue) -> String { + match value { + ProcedureValue::Scalar { value } => display_property_value(value), + ProcedureValue::List { values, .. } if values.is_empty() => "none".to_owned(), + ProcedureValue::List { values, .. } => values + .iter() + .map(display_property_value) + .collect::>() + .join(", "), + } +} + +fn display_property_value(value: &lab_capability::PropertyValue) -> String { + let scalar = match &value.value { + ScalarValue::Text(value) => value.clone(), + ScalarValue::Integer(value) => value.to_string(), + ScalarValue::Real(value) => value.to_string(), + ScalarValue::Boolean(value) => value.to_string(), + ScalarValue::Iri(value) => local_fragment(value.as_str()).to_owned(), + }; + match &value.unit { + Some(unit) => format!("{scalar} {}", local_fragment(unit.as_str())), + None => scalar, + } +} + fn manual_instructions( task: &AllocatedProcedureTask, binding: &AllocatedRequirementBinding, diff --git a/crates/lab-facility/src/lib.rs b/crates/lab-facility/src/lib.rs index 9fe54400..8ac55196 100644 --- a/crates/lab-facility/src/lib.rs +++ b/crates/lab-facility/src/lib.rs @@ -17,6 +17,7 @@ pub use adapters::{ }; pub use execution::{ ExecutionPlanBuildError, ExecutionPlanOptions, build_execution_plan_from_invocations, + manual_run_steps, }; pub use explain::explain_facility_planning_error; pub use inventory::{ diff --git a/crates/lab-ide/src/model.rs b/crates/lab-ide/src/model.rs index 5fc364ed..2f4a28cc 100644 --- a/crates/lab-ide/src/model.rs +++ b/crates/lab-ide/src/model.rs @@ -6,6 +6,8 @@ use serde::{Deserialize, Serialize}; pub enum SymbolKind { Module, Role, + Facet, + Action, Circuit, Artifact, Data, diff --git a/crates/lab-ide/src/semantic.rs b/crates/lab-ide/src/semantic.rs index 288659e4..82da4d27 100644 --- a/crates/lab-ide/src/semantic.rs +++ b/crates/lab-ide/src/semantic.rs @@ -5,15 +5,18 @@ use lab_language::{Span, ast}; use crate::{DocumentSymbol, SemanticToken, SemanticTokenKind, SymbolKind}; pub(crate) const KEYWORDS: &[&str] = &[ - "use", "role", "build", "buy", "is", "any", "circuit", "artifact", "record", "workflow", - "state", "require", "accept", "across", "declares", "if", "else", "for", "in", "match", "case", - "return", "when", "every", "after", "emit", "and", "or", "not", + "use", "role", "facet", "action", "requires", "on", "build", "buy", "is", "any", "circuit", + "artifact", "record", "workflow", "state", "require", "accept", "across", "declares", "if", + "else", "for", "in", "match", "case", "return", "when", "every", "after", "emit", "and", "or", + "not", ]; pub(crate) fn declaration(item: &ast::Item) -> Option<(&str, SymbolKind, Span)> { match item { ast::Item::Use(_) => None, ast::Item::Role(item) => Some((&item.name.value, SymbolKind::Role, item.name.span)), + ast::Item::Facet(item) => Some((&item.name.value, SymbolKind::Facet, item.name.span)), + ast::Item::Action(item) => Some((&item.name.value, SymbolKind::Action, item.name.span)), ast::Item::ArtifactKind(item) => Some((&item.name.value, SymbolKind::Data, item.name.span)), ast::Item::Circuit(item) => Some((&item.name.value, SymbolKind::Circuit, item.name.span)), ast::Item::Artifact(item) => Some((&item.name.value, SymbolKind::Artifact, item.name.span)), @@ -30,6 +33,8 @@ pub(crate) fn documentation(item: &ast::Item) -> Option<&str> { match item { ast::Item::Use(_) => None, ast::Item::Role(item) => item.doc.as_deref(), + ast::Item::Facet(item) => item.doc.as_deref(), + ast::Item::Action(item) => item.doc.as_deref(), ast::Item::ArtifactKind(item) => item.doc.as_deref(), ast::Item::Circuit(item) => item.doc.as_deref(), ast::Item::Artifact(item) => item.doc.as_deref(), @@ -181,6 +186,11 @@ fn collect_bound_names(ty: &ast::TypeExpr, out: &mut BTreeSet) { } // A forgotten argument introduces no name. ast::TypeArgument::Any { .. } => {} + // A narrowing introduces no name, but the subject it + // narrows may. + ast::TypeArgument::InState { subject, .. } => { + collect_bound_names(subject, out); + } ast::TypeArgument::Type(ty) => collect_bound_names(ty, out), } } @@ -208,6 +218,16 @@ fn semantic_names(module: Option<&ast::Module>) -> SemanticNames { ast::Item::Role(declaration) => { names.types.insert(declaration.name.value.clone()); } + // A facet shares the type namespace with roles and types, and like + // a role it only ever appears where a type is written. + ast::Item::Facet(declaration) => { + names.types.insert(declaration.name.value.clone()); + } + // A verb reads like the library operations it joins, so it colors + // as a function. + ast::Item::Action(declaration) => { + names.functions.insert(declaration.name.value.clone()); + } // A kind's word introduces declarations, so an editor colors it // like the keyword it behaves as. ast::Item::ArtifactKind(declaration) => { diff --git a/crates/lab-language-server/src/features.rs b/crates/lab-language-server/src/features.rs index 0163a782..9411d1af 100644 --- a/crates/lab-language-server/src/features.rs +++ b/crates/lab-language-server/src/features.rs @@ -302,6 +302,11 @@ fn symbol_kind(kind: SymbolKind) -> lsp::SymbolKind { // A role classifies types without describing values, which is what an // editor calls an interface. SymbolKind::Role => lsp::SymbolKind::INTERFACE, + // A facet is a closed set of named states, which is what an editor + // calls an enum. + SymbolKind::Facet => lsp::SymbolKind::ENUM, + // A verb is a named operation, which an editor calls a method. + SymbolKind::Action => lsp::SymbolKind::METHOD, SymbolKind::Circuit | SymbolKind::Workflow => lsp::SymbolKind::FUNCTION, SymbolKind::Artifact | SymbolKind::Data => lsp::SymbolKind::STRUCT, SymbolKind::Variable => lsp::SymbolKind::VARIABLE, diff --git a/crates/lab-language/src/ast.rs b/crates/lab-language/src/ast.rs index caa0032a..c5429a38 100644 --- a/crates/lab-language/src/ast.rs +++ b/crates/lab-language/src/ast.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; +use crate::checked::OwnershipMode; use crate::source::{Identifier, Span}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -46,6 +47,8 @@ pub fn instance_word(type_name: &str) -> String { pub enum Item { Use(UseDecl), Role(RoleDecl), + Facet(FacetDecl), + Action(ActionDecl), ArtifactKind(ArtifactKindDecl), Circuit(CircuitDecl), Artifact(ArtifactDecl), @@ -59,6 +62,8 @@ impl Item { match self { Self::Use(item) => item.span, Self::Role(item) => item.span, + Self::Facet(item) => item.span, + Self::Action(item) => item.span, Self::ArtifactKind(item) => item.span, Self::Circuit(item) => item.span, Self::Artifact(item) => item.span, @@ -95,6 +100,101 @@ pub struct UseDecl { pub span: Span, } +/// A durable laboratory verb a package declares. +/// +/// The header is the phrase a workflow writes, with each operand in `<>` and +/// each result named after `->`. The body types every operand and result, gives +/// an operand its ownership mode, and names the capability the verb needs. What +/// the compiler bundles as `centrifuge`, `chill`, and the rest is the same +/// shape a package supplies, so a new verb is a declaration rather than a +/// compiler change. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ActionDecl { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub doc: Option, + /// The verb, which is the phrase's first word. + pub name: Identifier, + /// The phrase in order: literal words and `` holes. + pub phrase: Vec, + /// The names a result binds, in order, listed after `->`. + pub results: Vec, + /// A type, and for an operand an ownership mode, for each named operand and + /// result. + pub bindings: Vec, + /// The capability a facility must offer to run this verb. + pub capability: Identifier, + pub span: Span, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "token", rename_all = "snake_case")] +pub enum PhraseToken { + Word(Identifier), + Hole(Identifier), +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ActionBinding { + pub name: Identifier, + /// The ownership mode, where this names an operand. A result is never owned + /// by the action, so it has none. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, + pub ty: TypeExpr, + pub span: Span, +} + +/// `facet Competence on Chassis` — how a kind's materials are classified by the +/// state they are in. +/// +/// A state is not a kind of thing, so it travels on the material rather than +/// becoming a second type. `Culture` and `Plate` were types for want of this, +/// which is why neither could name the design underneath it. +/// +/// Several facets may classify one kind, and they are independent. A culture +/// that is both diluted and grown under selection is two facets; flattening +/// them into one state naming both does not survive a third axis. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FacetDecl { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub doc: Option, + pub name: Identifier, + /// The type whose materials this facet classifies, written after `on`. + pub subject: TypeExpr, + /// The states in declaration order. The first is the state a newly + /// established material is in unless its declaration says otherwise. + pub states: Vec, + pub transitions: Vec, + pub span: Span, +} + +/// One state a material may be in, together with what that state carries. +/// +/// A state's fields are required. Knowing a batch of cells is competent without +/// knowing how competent is not a state worth distinguishing, so a field that +/// may be unstated belongs on the kind rather than on the state. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FacetStateDecl { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub doc: Option, + pub name: Identifier, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fields: Vec, + pub span: Span, +} + +/// `naive -> competent` — a state change an action may establish. +/// +/// Transitions are written rather than inferred from the actions a package +/// happens to declare, so the reachable states are a claim the kind makes and a +/// reviewer can read. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FacetTransitionDecl { + pub from: Identifier, + pub to: Identifier, + pub span: Span, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct CircuitDecl { pub doc: Option, @@ -455,7 +555,7 @@ pub enum TypeExpr { /// /// The argument is a unit rather than a type, so it is written the way a /// unit is written everywhere else: a name, optionally over a denominator. - Quantity { unit: String, span: Span }, + Quantity { unit: Unit, span: Span }, } impl TypeExpr { @@ -493,13 +593,48 @@ pub enum TypeArgument { role: Path, span: Span, }, + /// `Chassis is competent` — the subject, narrowed to one facet state. + /// + /// The state constrains the argument rather than wrapping `Material`, so + /// `Material` is still a material to every pass that + /// asks. Wrapping the material instead would take it out of ownership and + /// linearity analysis, which detect one by its outermost name. + InState { + subject: Box, + state: Identifier, + span: Span, + }, } impl TypeArgument { pub fn span(&self) -> Span { match self { Self::Type(ty) => ty.span(), - Self::Binding { span, .. } | Self::Any { span, .. } => *span, + Self::Binding { span, .. } | Self::Any { span, .. } | Self::InState { span, .. } => { + *span + } + } + } +} + +/// What a written quantity type is measured in. +/// +/// A field usually names the unit, because a thousandfold error is worth +/// refusing and the unit is what refuses it. A recipe holds measurements in +/// units its author chose, so it names the dimension instead and lets each +/// value keep its own unit. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Unit { + Exact(String), + Dimension(String), +} + +impl Unit { + pub fn written(&self) -> String { + match self { + Self::Exact(unit) => unit.clone(), + Self::Dimension(dimension) => format!("any {dimension}"), } } } @@ -550,6 +685,15 @@ pub enum Expr { field: Identifier, span: Span, }, + /// `500 mL in uL` — the same measurement written in another unit. + /// + /// Conversion is written rather than implied, so a unit still means what it + /// says everywhere else and a thousandfold error stays a diagnostic. + Convert { + value: Box, + unit: Identifier, + span: Span, + }, Unary { op: UnaryOp, operand: Box, @@ -575,6 +719,7 @@ impl Expr { | Self::Call { span, .. } | Self::Record { span, .. } | Self::Field { span, .. } + | Self::Convert { span, .. } | Self::Unary { span, .. } | Self::Binary { span, .. } => *span, } diff --git a/crates/lab-language/src/checked.rs b/crates/lab-language/src/checked.rs index f4655558..34526f8f 100644 --- a/crates/lab-language/src/checked.rs +++ b/crates/lab-language/src/checked.rs @@ -19,12 +19,16 @@ use crate::semantics::{DefinitionId, ModuleId, ModuleInterface}; /// produced type plays, and artifact instances preserve exact SBOL identities /// independently of laboratory provenance. Durable actions preserve stable /// Intent operation identities, typed values, ownership, and exact workflow -/// callees, while Method definitions separately own capability refinement. +/// callees, while Method definitions separately own capability refinement. A +/// facet is a declaration of its own, carrying the states a kind's materials +/// may be in and the changes between them, a type argument may be narrowed to one +/// of those states, and an action declares a durable verb with its phrase, +/// operands, results, and capability. /// /// Grounding, design identities, and Intent operation 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.v9"; +pub const PORTABLE_MODULE_SCHEMA_VERSION: &str = "lab.portable-module.v11"; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CheckedModule { @@ -59,6 +63,42 @@ pub enum CheckedDeclaration { #[serde(default, skip_serializing_if = "Option::is_none")] term: Option, }, + /// How a kind's materials are classified by the state they are in. + /// + /// A facet is what keeps a state off the type. Several facets may classify + /// one kind and they stay independent, so a material in two states at once + /// is two facets rather than one state naming both. + Facet { + #[serde(default, skip_serializing_if = "Option::is_none")] + doc: Option, + name: String, + /// The type whose materials this facet classifies. + subject: CheckedType, + /// The states in declaration order. The first is the initial state. + states: Vec, + /// The state changes this facet admits, as `(from, to)` pairs. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + transitions: Vec, + }, + /// A durable laboratory verb a package declares. + /// + /// The phrase a workflow writes, its operands and results and their types, + /// and the capability it needs travel together, so an importer checks a + /// workflow against it and the compiler derives a manual method to run it. + Action { + #[serde(default, skip_serializing_if = "Option::is_none")] + doc: Option, + /// The verb, which is the phrase's first word. + name: String, + /// The Intent operation this verb refines, namespaced by its module. + operation: String, + /// The phrase a workflow writes, its words and operand holes in order. + phrase: Vec, + operands: Vec, + results: Vec, + /// The capability a facility must offer to run this verb. + capability: String, + }, /// A name a supplier lists, and the Lab type it stands for. /// /// Biological identity and supplier identity are separate fields rather @@ -172,6 +212,17 @@ pub enum CheckedType { Any { role: String, }, + /// A measurement of a stated thing, in whatever unit it was written in. + Measuring { + dimension: String, + }, + /// A type argument narrowed to one facet state, written `Chassis is + /// competent`. It appears only as an argument, so a narrowed material is + /// still a `Material` to anything reading the outer type. + InState { + subject: Box, + state: String, + }, Integer, Decimal, String, @@ -180,6 +231,19 @@ pub enum CheckedType { } impl CheckedType { + /// This type with any state narrowing removed. + /// + /// A narrowing says which state a thing is in, never what it is, so a reader + /// that wants the underlying kind asks for the subject. Without this, every + /// consumer matching on `Named` would silently stop recognizing a thing the + /// moment its state was stated. + pub fn subject(&self) -> &Self { + match self { + Self::InState { subject, .. } => subject.subject(), + other => other, + } + } + pub fn display_name(&self) -> String { match self { Self::Named { name, arguments } if arguments.is_empty() => name.clone(), @@ -199,6 +263,8 @@ impl CheckedType { Self::List { element } => format!("List<{}>", element.display_name()), Self::Quantity { unit } => format!("Quantity<{unit}>"), Self::Any { role } => format!("any {role}"), + Self::Measuring { dimension } => format!("Quantity"), + Self::InState { subject, state } => format!("{} is {state}", subject.display_name()), Self::Integer => "Integer".to_owned(), Self::Decimal => "Decimal".to_owned(), Self::String => "String".to_owned(), @@ -276,6 +342,46 @@ pub struct CheckedSchemaField { pub optional: bool, } +/// One token of an action's phrase: a literal word or an operand hole. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "token", rename_all = "snake_case")] +pub enum CheckedPhraseToken { + Word(String), + Hole(String), +} + +/// One operand an action takes: its name, its type, and how the action owns it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CheckedActionOperand { + pub name: String, + pub r#type: CheckedType, + pub mode: OwnershipMode, +} + +/// One result an action yields: its name and the type it arrives as. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CheckedActionResult { + pub name: String, + pub r#type: CheckedType, +} + +/// One state a facet admits, together with what a material in it carries. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CheckedFacetState { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub doc: Option, + pub name: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fields: Vec, +} + +/// A state change a facet admits. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CheckedFacetTransition { + pub from: String, + pub to: String, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CheckedCase { pub name: String, @@ -400,6 +506,14 @@ pub struct ResolvedAction { /// already the stable semantic operation identity. #[serde(default, skip_serializing_if = "Option::is_none")] pub callee: Option, + /// The capability a facility must offer to run a declared verb. + /// + /// A verb declared with `action` states the one capability it needs, and it + /// travels with the call so the compiler can derive a method that requires + /// it. The six bundled verbs carry their capability in their own lowering, + /// so this is absent for them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capability: Option, pub arguments: Vec, pub results: Vec, } diff --git a/crates/lab-language/src/checker/action_contract.rs b/crates/lab-language/src/checker/action_contract.rs index 43e38357..ac9e7534 100644 --- a/crates/lab-language/src/checker/action_contract.rs +++ b/crates/lab-language/src/checker/action_contract.rs @@ -32,7 +32,7 @@ impl Checker { // A clause is present exactly when the word introducing it is, // so an omitted one is not mistaken for a malformed phrase. let introducer = match clause.first() { - Some(PhrasePart::Word(word)) => *word, + Some(PhrasePart::Word(word)) => word.as_str(), _ => unreachable!("contract validation requires a leading word"), }; if words.get(cursor) == Some(&introducer) { @@ -106,7 +106,7 @@ impl Checker { unreachable!("contract validation rejects a nested optional clause") } PhrasePart::Word(expected) => { - if words.get(*cursor) != Some(expected) { + if words.get(*cursor) != Some(&expected.as_str()) { return Err(SemanticError::new( effect.span, format!("malformed '{}' action phrase", contract.operation), @@ -156,7 +156,7 @@ impl Checker { actual.clone(), expected, effect.span, - contract.operation, + &contract.operation, )?; } operands.insert((*name).to_owned(), actual.clone()); @@ -199,7 +199,21 @@ impl Checker { ), ) })?; - checked_integer_literal(magnitude, *signed, effect.span)?; + // A measured operand carries a decimal magnitude: an optical + // density is 0.40, not 0. The magnitude rides through as the + // text it was written as, so the check is that it is a number + // and, where the operand is unsigned, that it is not negative. + let negative = magnitude.starts_with('-'); + if crate::units::Decimal::parse(magnitude).is_none() || (!*signed && negative) { + return Err(SemanticError::new( + effect.span, + format!( + "action '{}' expects a {}number for '{name}', found '{magnitude}'", + contract.operation, + if *signed { "" } else { "non-negative " }, + ), + )); + } let unit = words.get(*cursor + 1).ok_or_else(|| { SemanticError::new( effect.span, @@ -209,7 +223,7 @@ impl Checker { ), ) })?; - if !units.contains(unit) { + if !units.iter().any(|allowed| allowed == unit) { return Err(SemanticError::new( effect.span, format!( @@ -248,7 +262,7 @@ impl Checker { .iter() .map(|result| { let ty = resolve_contract_type(&result.r#type, &operands, effect.span)?; - Ok((super::checked_field(result.name, &ty), ty)) + Ok((super::checked_field(&result.name, &ty), ty)) }) .collect::, SemanticError>>()?; let results = result_contracts @@ -263,6 +277,7 @@ impl Checker { ResolvedAction { operation: contract.operation.to_owned(), callee: None, + capability: None, arguments, results: checked_results, }, @@ -278,14 +293,14 @@ pub(super) fn resolve_contract_type( ) -> Result { match r#type { ContractType::Concrete(ty) => Ok(ty.clone()), - ContractType::SameAs(name) => operands.get(*name).cloned().ok_or_else(|| { + ContractType::SameAs(name) => operands.get(name.as_str()).cloned().ok_or_else(|| { SemanticError::new( span, format!("action contract references unknown operand '{name}'"), ) }), ContractType::MaterialOf(name) => operands - .get(*name) + .get(name.as_str()) .map(|ty| Ty::material(ty.clone())) .ok_or_else(|| { SemanticError::new( diff --git a/crates/lab-language/src/checker/context.rs b/crates/lab-language/src/checker/context.rs index 1de23ea4..c8bd6eb6 100644 --- a/crates/lab-language/src/checker/context.rs +++ b/crates/lab-language/src/checker/context.rs @@ -7,13 +7,15 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use crate::ast::Path; -use crate::checked::CheckedType; +use crate::checked::{CheckedActionOperand, CheckedActionResult, CheckedPhraseToken, CheckedType}; use crate::semantic_error::SemanticError; -use crate::semantics::{DefinitionId, ExportKind, ModuleId, ModuleInterface, SemanticEnvironment}; +use crate::semantics::{ + ActionSurface, DefinitionId, ExportKind, ModuleId, ModuleInterface, SemanticEnvironment, +}; use crate::source::Span; use crate::standard_library::{ - ActionContractSpec, ConstructorSpec, PureFunctionSpec, StandardLibrary, StandardModule, - TypeSpec, + ActionContractSpec, ConstructorSpec, ContractType, Lineage, PhrasePart, PureFunctionSpec, + ResultSpec, StandardLibrary, StandardModule, TypeSpec, }; use crate::type_system::{Ty, from_checked_type}; @@ -65,6 +67,106 @@ pub(super) struct ArtifactKindSignature { pub declares: Option, } +/// Rebuild the contract a workflow checks against from an imported action's +/// surface. +/// +/// A hole becomes a quantity slot for a measurement, an integer slot for a +/// whole number, and an operand otherwise, exactly as the declaring module +/// decided; the surface carries enough to make the same choice. +fn action_contract_from_surface(surface: &ActionSurface) -> ActionContractSpec { + let operand = |name: &str| { + surface + .operands + .iter() + .find(|operand| operand.name == name) + .expect("an action surface names every operand its phrase holds") + }; + let phrase = surface + .phrase + .iter() + .map(|token| match token { + CheckedPhraseToken::Word(word) => PhrasePart::word(word), + CheckedPhraseToken::Hole(name) => { + let operand = operand(name); + match from_checked_type(&operand.r#type) { + Ty::Quantity(unit) => PhrasePart::quantity(name, false, &[unit.as_str()]), + // A union of measurements is one operand read in any of its + // units, the way a growth endpoint is written as OD600 or + // OD700. The reconstruction mirrors what the declaration + // collected, so an imported verb reads the same phrase. + Ty::Union(alternatives) + if !alternatives.is_empty() + && alternatives + .iter() + .all(|alternative| matches!(alternative, Ty::Quantity(_))) => + { + let units = alternatives + .iter() + .map(|alternative| match alternative { + Ty::Quantity(unit) => unit.as_str(), + _ => unreachable!("every alternative is a measurement"), + }) + .collect::>(); + PhrasePart::quantity(name, false, &units) + } + Ty::Integer => PhrasePart::integer(name, false), + ty => PhrasePart::operand(name, ContractType::Concrete(ty), operand.mode), + } + } + }) + .collect(); + let results = surface + .results + .iter() + .map(|result| ResultSpec { + name: result.name.clone(), + r#type: ContractType::Concrete(from_checked_type(&result.r#type)), + lineage: Lineage::Continues, + }) + .collect(); + ActionContractSpec { + operation: surface.operation.clone(), + phrase, + results, + inert: Vec::new(), + } +} + +/// The full contract of an action declared in source. +#[derive(Clone)] +pub(super) struct CheckedActionContract { + pub operation: String, + pub phrase: Vec, + pub operands: Vec, + pub results: Vec, + pub capability: String, +} + +/// A facet in scope: the type it classifies, its states, and the state changes +/// it admits. +#[derive(Clone)] +pub(super) struct FacetSignature { + pub subject: Ty, + /// The states in declaration order, so the first stays identifiable as the + /// state a newly established material is in. + pub states: Vec, + pub transitions: Vec<(String, String)>, +} + +impl FacetSignature { + /// The state this facet admits under that name, if it admits one. + pub fn state(&self, name: &str) -> Option<&FacetState> { + self.states.iter().find(|state| state.name == name) + } +} + +#[derive(Clone)] +pub(super) struct FacetState { + pub doc: Option, + pub name: String, + pub fields: BTreeMap, +} + #[derive(Clone)] pub(super) struct WorkflowSignature { pub generics: Generics, @@ -85,6 +187,13 @@ pub(super) struct SemanticContext { pub pure_functions: HashMap, pub constructors: HashMap, pub actions: HashMap, + /// The full contract of each action declared in source, by verb. + /// + /// The action map above is what a workflow checks against, shared with the + /// standard library. This carries the extra a source declaration states and + /// exports: the phrase, the operands and results with their types, and the + /// capability, so the compiler can derive a method to run the verb. + pub action_contracts: HashMap, pub circuits: HashMap, pub data: HashMap, pub cases: HashMap, @@ -104,6 +213,14 @@ pub(super) struct SemanticContext { /// SBOL document states about it. A role with no term classifies types and /// says nothing about any ontology. pub role_terms: HashMap, + /// Every facet in scope, by facet name. + pub facets: HashMap, + /// The facets classifying each subject type, by the type's name. + /// + /// A material type constrains a state without naming the facet it belongs + /// to, so resolving `Material` starts from the + /// subject and asks which of its facets admits that state. + pub type_facets: HashMap>, } impl SemanticContext { @@ -132,6 +249,7 @@ impl SemanticContext { pure_functions: HashMap::new(), constructors: HashMap::new(), actions: HashMap::new(), + action_contracts: HashMap::new(), circuits: HashMap::new(), data: HashMap::new(), cases: HashMap::new(), @@ -141,6 +259,8 @@ impl SemanticContext { roles: BTreeSet::new(), type_roles: HashMap::new(), role_terms: HashMap::new(), + facets: HashMap::new(), + type_facets: HashMap::new(), } } @@ -314,6 +434,53 @@ impl SemanticContext { // Types and roles are registered above, before anything that // could refer to them. ExportKind::Type | ExportKind::Role => {} + // A facet means nothing without its states, so the two travel + // together the way a kind travels with its schema. + ExportKind::Facet => { + if let Some(surface) = &export.facet { + let subject = from_checked_type(&surface.subject); + if let Ty::Named(subject_name, _) = &subject { + self.type_facets + .entry(subject_name.clone()) + .or_default() + .insert(name.clone()); + } + self.facets.insert( + name.clone(), + FacetSignature { + subject, + states: surface + .states + .iter() + .map(|state| FacetState { + doc: state.doc.clone(), + name: state.name.clone(), + fields: state + .fields + .iter() + .map(|field| { + ( + field.name.clone(), + SchemaField { + ty: from_checked_type(&field.r#type), + optional: field.optional, + }, + ) + }) + .collect(), + }) + .collect(), + transitions: surface + .transitions + .iter() + .map(|transition| { + (transition.from.clone(), transition.to.clone()) + }) + .collect(), + }, + ); + } + } // A word a package supplies means nothing without its schema, // so the two travel together. ExportKind::ArtifactKind => { @@ -396,7 +563,27 @@ impl SemanticContext { ); } } - ExportKind::Workflow | ExportKind::Action => { + ExportKind::Action => { + self.insert_imported_name(interface.module.as_str(), name, span)?; + if let Some(surface) = &export.action { + self.actions + .insert(name.clone(), action_contract_from_surface(surface)); + // The capability an imported verb needs travels with its + // surface, so a workflow that performs it can carry the + // capability to the method the compiler derives. + self.action_contracts.insert( + name.clone(), + CheckedActionContract { + operation: surface.operation.clone(), + phrase: surface.phrase.clone(), + operands: surface.operands.clone(), + results: surface.results.clone(), + capability: surface.capability.clone(), + }, + ); + } + } + ExportKind::Workflow => { self.insert_imported_name(interface.module.as_str(), name, span)?; if let Some(signature) = &export.callable { self.workflows.insert( diff --git a/crates/lab-language/src/checker/declarations.rs b/crates/lab-language/src/checker/declarations.rs index 7ef5a81a..433f9311 100644 --- a/crates/lab-language/src/checker/declarations.rs +++ b/crates/lab-language/src/checker/declarations.rs @@ -5,22 +5,27 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use crate::ast::{ - ArtifactDecl, ArtifactMember, CircuitDecl, DataDecl, Expr, FieldDecl, Item, Module, Path, - Provenance, TypeArgument, TypeExpr, WorkflowOutputs, + ActionDecl, ArtifactDecl, ArtifactMember, CircuitDecl, DataDecl, Expr, FacetDecl, FieldDecl, + Item, Module, Path, PhraseToken, Provenance, TypeArgument, TypeExpr, Unit, WorkflowOutputs, + instance_word, }; use crate::checked::{ - CheckedAcceptance, CheckedCase, CheckedDeclaration, CheckedPresence, CheckedProperty, - CheckedSection, + CheckedAcceptance, CheckedActionOperand, CheckedActionResult, CheckedCase, CheckedDeclaration, + CheckedPhraseToken, CheckedPresence, CheckedProperty, CheckedSection, OwnershipMode, }; use crate::is_absolute_iri; use crate::semantic_error::SemanticError; use crate::source::{Identifier, Span}; +use crate::standard_library::{ + ActionContractSpec, ContractType, Lineage as ContractLineage, PhrasePart as ContractPhrasePart, + ResultSpec as ContractResultSpec, +}; use crate::type_system::{Ty, to_checked_type}; use super::Checker; use super::context::{ - ArtifactKindSignature, CircuitSignature, DataSignature, Generics, SchemaField, - WorkflowSignature, + ArtifactKindSignature, CheckedActionContract, CircuitSignature, DataSignature, FacetSignature, + FacetState, Generics, SchemaField, WorkflowSignature, }; /// Where a type parameter's name appears in a signature, in source order. @@ -65,6 +70,9 @@ fn collect_mentions<'a>(ty: &'a TypeExpr, out: &mut Vec>) { // A forgotten argument introduces no name and refers to // none, so it takes no part in signature scoping. TypeArgument::Any { .. } => {} + // A narrowing introduces no name either, but the subject it + // narrows is an ordinary argument and may mention one. + TypeArgument::InState { subject, .. } => collect_mentions(subject, out), TypeArgument::Type(ty) => collect_mentions(ty, out), } } @@ -104,6 +112,7 @@ fn first_mention<'a>( } => mention(callee).or_else(|| arguments.iter().find_map(|it| mention(&it.value))), Expr::Record { fields, .. } => fields.iter().find_map(|it| mention(&it.value)), Expr::Field { subject, .. } => mention(subject), + Expr::Convert { value, .. } => mention(value), Expr::Unary { operand, .. } => mention(operand), Expr::Binary { left, right, .. } => mention(left).or_else(|| mention(right)), Expr::Integer { .. } | Expr::Decimal { .. } | Expr::String { .. } => None, @@ -155,6 +164,49 @@ fn conjunction(names: &[&str]) -> String { } } +/// The kind a facet classifies, which is a bare name and never a built type. +/// +/// A facet says which states a kind's materials may be in. A list or a +/// measurement has no states, and a generic subject would make the states depend +/// on an argument, so the subject is one name. +fn facet_subject(declaration: &FacetDecl) -> Result<&Identifier, SemanticError> { + match &declaration.subject { + TypeExpr::Path { + path, arguments, .. + } if arguments.is_empty() + && let [segment] = path.segments.as_slice() => + { + Ok(segment) + } + other => Err( + SemanticError::new(other.span(), "a facet classifies a named kind") + .help("write 'on' followed by the kind whose materials this facet classifies"), + ), + } +} + +/// The facet a property name states, if it names one. +/// +/// A facet is stated by its own name in snake_case, the way an artifact kind's +/// instances are written with the type's name in snake_case. One convention +/// serves both, so neither has to be learned separately. +fn stated_facet(checker: &Checker, produces: &Ty, name: &str) -> Option { + let Ty::Named(subject, _) = produces else { + return None; + }; + checker + .type_facets + .get(subject)? + .iter() + .find(|facet| instance_word(facet) == name) + .cloned() +} + +fn facet_state_expected(facet: &str, span: Span) -> SemanticError { + SemanticError::new(span, format!("'{facet}' is not a state")) + .help("a facet is stated as one of its states, written as a bare name") +} + fn quoted_conjunction(names: &[&str]) -> String { let quoted = names .iter() @@ -206,6 +258,8 @@ impl Checker { let (name, span) = match item { Item::Use(_) => continue, Item::Role(value) => (&value.name.value, value.name.span), + Item::Facet(value) => (&value.name.value, value.name.span), + Item::Action(value) => (&value.name.value, value.name.span), Item::ArtifactKind(value) => (&value.name.value, value.name.span), Item::Circuit(value) => (&value.name.value, value.name.span), Item::Artifact(value) => (&value.name.value, value.name.span), @@ -263,6 +317,26 @@ impl Checker { } } + // Facets resolve before any signature, because a signature may narrow a + // material to a state and the states have to be known by then. They ask + // only for the subject's name, so a kind declared further down the file + // is not yet needed; that the name is a real kind is checked once every + // declaration has been seen. + for item in &module.items { + if let Item::Facet(declaration) = item { + self.collect_facet(declaration)?; + } + } + + // Actions register after facets, because an operand may be narrowed to a + // facet state and the states must be known by then. A verb the workflow + // pass then reads is checked against the contract collected here. + for item in &module.items { + if let Item::Action(declaration) = item { + self.collect_action(declaration)?; + } + } + for item in &module.items { match item { Item::Circuit(declaration) => { @@ -289,7 +363,8 @@ impl Checker { }, ); } - Item::Role(_) => {} + // Each is collected in a pass of its own. + Item::Role(_) | Item::Facet(_) | Item::Action(_) => {} Item::ArtifactKind(declaration) => { let produces = self.lower_kind_type(&declaration.produces)?; // A kind's roles classify the type it produces, because @@ -467,11 +542,412 @@ impl Checker { } None => ty, }; + // A stated facet narrows the thing itself, so every use of + // the name carries the state. Provisioning a chassis + // declared competent then yields competent cells without + // the action contract knowing that facets exist. + let ty = self.narrowed_by_stated_facets(&ty, declaration)?; self.values.insert(declaration.name.value.clone(), ty); } Item::Use(_) | Item::Binding(_) => {} } } + + for item in &module.items { + if let Item::Facet(declaration) = item { + let subject = facet_subject(declaration)?; + if !self.known_types.contains(&subject.value) + && !self.artifact_kinds.values().any(|kind| { + matches!(&kind.produces, Ty::Named(name, _) if name == &subject.value) + }) + && !self.standard_types.contains_key(&subject.value) + { + return Err(SemanticError::new( + subject.span, + format!("'{}' is not a kind in scope", subject.value), + ) + .help("a facet classifies the materials of a declared kind")); + } + } + } + Ok(()) + } + + /// Narrow an instance's type by the facet states its declaration states. + /// + /// A property whose name is a facet on this kind says which state this thing + /// is in. It is not a schema field: the kind declares what a thing *may* + /// state, and a facet declares what it may *be*, so the two namespaces are + /// checked separately and a facet name is never a missing property. + fn narrowed_by_stated_facets( + &self, + ty: &Ty, + declaration: &ArtifactDecl, + ) -> Result { + let Ty::Named(subject, _) = ty else { + return Ok(ty.clone()); + }; + let Some(facets) = self.type_facets.get(subject) else { + return Ok(ty.clone()); + }; + let mut narrowed = ty.clone(); + for member in &declaration.members { + let ArtifactMember::Property(property) = member else { + continue; + }; + let Some(facet) = facets + .iter() + .find(|facet| instance_word(facet) == property.name.value) + else { + continue; + }; + let signature = self + .facets + .get(facet) + .expect("a facet on this type was collected"); + let state = match &property.value { + Expr::Path(path) => match path.segments.as_slice() { + [segment] => segment, + _ => return Err(facet_state_expected(&property.name.value, path.span)), + }, + other => return Err(facet_state_expected(&property.name.value, other.span())), + }; + if signature.state(&state.value).is_none() { + let states = signature + .states + .iter() + .map(|state| state.name.as_str()) + .collect::>(); + return Err(SemanticError::new( + state.span, + format!("facet '{facet}' has no state '{}'", state.value), + ) + .help(format!("states of '{facet}': {}", states.join(", ")))); + } + narrowed = Ty::InState(Box::new(narrowed), state.value.clone()); + } + Ok(narrowed) + } + + /// The facet states this declaration puts itself in. + /// + /// The properties were validated when the type was narrowed, so this reads + /// the same pairs back rather than checking them twice. + fn stated_facet_states( + &self, + ty: &Ty, + declaration: &ArtifactDecl, + ) -> Result, SemanticError> { + let mut stated = Vec::new(); + for member in &declaration.members { + let ArtifactMember::Property(property) = member else { + continue; + }; + let Some(facet) = stated_facet(self, ty, &property.name.value) else { + continue; + }; + let Expr::Path(path) = &property.value else { + continue; + }; + let [segment] = path.segments.as_slice() else { + continue; + }; + stated.push((facet, segment.value.clone())); + } + Ok(stated) + } + + /// Check that some facet of `subject` admits `state`. + /// + /// A material is narrowed by naming a state rather than the facet it belongs + /// to, because at the point of use the state is what a person knows: cells + /// are competent, a culture is diluted. Which facet said so is the + /// declaration's business. + fn check_facet_state(&self, subject: &Ty, state: &Identifier) -> Result<(), SemanticError> { + let Ty::Named(subject_name, _) = subject else { + return Err(SemanticError::new( + state.span, + format!("'{subject}' has no states, so it cannot be narrowed to one"), + ) + .help("only a named kind carries facets")); + }; + let facets = self.type_facets.get(subject_name); + let admits = facets.is_some_and(|names| { + names.iter().any(|facet| { + self.facets + .get(facet) + .is_some_and(|signature| signature.state(&state.value).is_some()) + }) + }); + if admits { + return Ok(()); + } + let known = facets + .map(|names| { + let mut states = names + .iter() + .filter_map(|facet| self.facets.get(facet)) + .flat_map(|signature| signature.states.iter().map(|state| state.name.as_str())) + .collect::>(); + states.sort_unstable(); + states + }) + .unwrap_or_default(); + let error = SemanticError::new( + state.span, + format!("'{subject_name}' has no state '{}'", state.value), + ); + Err(if known.is_empty() { + error.help(format!( + "no facet classifies '{subject_name}'; declare one with 'facet on {subject_name}:'" + )) + } else { + error.help(format!("states of '{subject_name}': {}", known.join(", "))) + }) + } + + /// Validate one action and register its contract so workflows check + /// against it. + /// + /// The phrase's holes are typed by the body, and each hole's type decides + /// the part it becomes: a measurement is a quantity slot, a whole number an + /// integer slot, and anything else a material or value operand. + fn collect_action(&mut self, declaration: &ActionDecl) -> Result<(), SemanticError> { + let mut binding_types = BTreeMap::new(); + let mut binding_modes = BTreeMap::new(); + for binding in &declaration.bindings { + let ty = self.lower_type(&binding.ty, &BTreeSet::new())?; + if binding_types + .insert(binding.name.value.clone(), ty) + .is_some() + { + return Err(SemanticError::new( + binding.name.span, + format!("'{}' is typed more than once", binding.name.value), + )); + } + binding_modes.insert(binding.name.value.clone(), binding.mode); + } + + let hole = |name: &Identifier| -> Result { + binding_types.get(&name.value).cloned().ok_or_else(|| { + SemanticError::new( + name.span, + format!( + "operand '{}' is named in the phrase but never typed", + name.value + ), + ) + }) + }; + + let mut phrase = Vec::new(); + let mut phrase_tokens = Vec::new(); + for token in &declaration.phrase { + match token { + PhraseToken::Word(word) => { + phrase.push(ContractPhrasePart::word(&word.value)); + phrase_tokens.push(CheckedPhraseToken::Word(word.value.clone())); + } + PhraseToken::Hole(name) => { + let ty = hole(name)?; + phrase.push(self.action_operand_part(name, &ty, binding_modes[&name.value])?); + phrase_tokens.push(CheckedPhraseToken::Hole(name.value.clone())); + } + } + } + + let mut operands = Vec::new(); + for token in &declaration.phrase { + if let PhraseToken::Hole(name) = token { + let ty = hole(name)?; + operands.push(CheckedActionOperand { + name: name.value.clone(), + r#type: to_checked_type(&ty), + mode: binding_modes[&name.value].unwrap_or(OwnershipMode::Take), + }); + } + } + + let mut results = Vec::new(); + let mut result_specs = Vec::new(); + for name in &declaration.results { + let ty = hole(name)?; + results.push(CheckedActionResult { + name: name.value.clone(), + r#type: to_checked_type(&ty), + }); + result_specs.push(ContractResultSpec { + name: name.value.clone(), + r#type: ContractType::Concrete(ty), + lineage: ContractLineage::Continues, + }); + } + + let operation = format!("{}.{}", self.module_id.as_str(), declaration.name.value); + let contract = ActionContractSpec { + operation: operation.clone(), + phrase, + results: result_specs, + inert: Vec::new(), + }; + contract.validate().map_err(|message| { + SemanticError::new( + declaration.name.span, + format!("malformed action: {message}"), + ) + })?; + self.actions + .insert(declaration.name.value.clone(), contract); + self.action_contracts.insert( + declaration.name.value.clone(), + CheckedActionContract { + operation, + phrase: phrase_tokens, + operands, + results, + capability: declaration.capability.value.clone(), + }, + ); + Ok(()) + } + + /// The phrase part one operand hole becomes, chosen by its type. + fn action_operand_part( + &self, + name: &Identifier, + ty: &Ty, + mode: Option, + ) -> Result { + match ty { + Ty::Quantity(unit) => Ok(ContractPhrasePart::quantity(&name.value, false, &[unit])), + // A union of measurements is one operand read in any of its units, + // which is how a growth endpoint reaches a target the plate reader + // reports as OD600 or OD700: the number is written in whichever the + // meter gives, and the two do not convert. + Ty::Union(alternatives) + if !alternatives.is_empty() + && alternatives + .iter() + .all(|alternative| matches!(alternative, Ty::Quantity(_))) => + { + let units = alternatives + .iter() + .map(|alternative| match alternative { + Ty::Quantity(unit) => unit.as_str(), + _ => unreachable!("every alternative is a measurement"), + }) + .collect::>(); + Ok(ContractPhrasePart::quantity(&name.value, false, &units)) + } + Ty::Integer => Ok(ContractPhrasePart::integer(&name.value, false)), + Ty::Measuring(_) => Err(SemanticError::new( + name.span, + format!( + "operand '{}' must state one unit, not a dimension", + name.value + ), + ) + .help("an action operand pins its unit; a field may name a dimension")), + _ => Ok(ContractPhrasePart::operand( + &name.value, + ContractType::Concrete(ty.clone()), + mode.unwrap_or(OwnershipMode::Take), + )), + } + } + + /// Validate one facet and register it against the type it classifies. + fn collect_facet(&mut self, declaration: &FacetDecl) -> Result<(), SemanticError> { + let subject_name = facet_subject(declaration)?.value.clone(); + let subject = Ty::named(subject_name.clone()); + + let mut states: Vec = Vec::new(); + for state in &declaration.states { + if states.iter().any(|seen| seen.name == state.name.value) { + return Err(SemanticError::new( + state.name.span, + format!("duplicate state '{}'", state.name.value), + ) + .help("each state a facet admits is listed once")); + } + let mut fields = BTreeMap::new(); + for field in &state.fields { + let ty = self.lower_type(&field.ty, &BTreeSet::new())?; + fields.insert( + field.name.value.clone(), + SchemaField { + ty, + optional: field.optional, + }, + ); + } + states.push(FacetState { + doc: state.doc.clone(), + name: state.name.value.clone(), + fields, + }); + } + + let known = |name: &Identifier| states.iter().any(|state| state.name == name.value); + let mut transitions = Vec::new(); + for transition in &declaration.transitions { + for endpoint in [&transition.from, &transition.to] { + if !known(endpoint) { + return Err(SemanticError::new( + endpoint.span, + format!( + "facet '{}' has no state '{}'", + declaration.name.value, endpoint.value + ), + ) + .help(format!( + "states in this facet: {}", + states + .iter() + .map(|state| state.name.as_str()) + .collect::>() + .join(", ") + ))); + } + } + transitions.push((transition.from.value.clone(), transition.to.value.clone())); + } + + // The first state is where a material starts, so it needs no transition + // into it. Any other state nothing reaches cannot be established, and a + // state no action can establish is a claim the kind cannot honor. + for state in states.iter().skip(1) { + if !transitions.iter().any(|(_, to)| to == &state.name) { + let span = declaration + .states + .iter() + .find(|declared| declared.name.value == state.name) + .map(|declared| declared.name.span) + .unwrap_or(declaration.name.span); + return Err(SemanticError::new( + span, + format!("no transition reaches state '{}'", state.name), + ) + .help(format!( + "a material starts in '{}'; write a transition into '{}' or remove it", + states[0].name, state.name + ))); + } + } + + self.type_facets + .entry(subject_name.clone()) + .or_default() + .insert(declaration.name.value.clone()); + self.facets.insert( + declaration.name.value.clone(), + FacetSignature { + subject, + states, + transitions, + }, + ); Ok(()) } @@ -913,6 +1389,23 @@ impl Checker { ) .help("'require', 'accept', and 'across' describe a thing a laboratory makes")); } + // What a material in a state carries is stated alongside the state, so + // the fields of every state this declaration puts itself in are + // readable here exactly as the kind's own schema fields are. + let stated_states = self.stated_facet_states(&produces, declaration)?; + let mut state_fields: BTreeMap = BTreeMap::new(); + for (facet, state) in &stated_states { + let Some(state) = self.facets.get(facet).and_then(|facet| facet.state(state)) else { + continue; + }; + for (name, field) in &state.fields { + state_fields.insert(name.clone(), field.ty.clone()); + } + } + for (name, ty) in &state_fields { + environment.insert(name.clone(), ty.clone()); + } + let mut properties = Vec::new(); let mut property_names = BTreeSet::new(); let mut sbol_identity = None; @@ -994,14 +1487,27 @@ impl Checker { // 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) { + // A facet name says which state this thing is in. It narrowed + // the value's type when the declaration was collected, and + // it is not a schema field, so it is neither checked against + // one nor reported as missing from one. + if stated_facet(self, &produces, &property.name.value).is_some() { + continue; + } + if !signature.fields.contains_key(&property.name.value) + && !state_fields.contains_key(&property.name.value) + { let mut error = SemanticError::new( property.name.span, format!("{produces} has no property '{}'", property.name.value), ); if let Some(near) = nearest( &property.name.value, - signature.fields.keys().map(String::as_str), + signature + .fields + .keys() + .chain(state_fields.keys()) + .map(String::as_str), ) { error = error.help(format!("did you mean '{near}'?")); } @@ -1091,6 +1597,41 @@ impl Checker { ) .help(format!("every {keyword} states {}", conjunction(&required)))); } + // A state carries what is true of a material in it, so stating the + // state and leaving those unstated says less than the facet promised. + // Cells are not competent in the abstract; they are competent to a + // number, and that number is what a batch is accepted on. + for (facet, state) in &stated_states { + let signature = self + .facets + .get(facet) + .expect("a facet on this type was collected"); + let state = signature + .state(state) + .expect("the stated state was validated when the type was narrowed"); + let missing = state + .fields + .keys() + .filter(|field| !property_names.contains(field.as_str())) + .map(String::as_str) + .collect::>(); + if !missing.is_empty() { + return Err(SemanticError::new( + declaration.span, + format!( + "'{}' is {} but does not state {}", + declaration.name.value, + state.name, + quoted_conjunction(&missing) + ), + ) + .help(format!( + "'{}' carries {}", + state.name, + conjunction(&state.fields.keys().map(String::as_str).collect::>()) + ))); + } + } if let Some(predicate) = &signature.declares && !predicate.satisfied_by(&property_names) { @@ -1107,7 +1648,15 @@ impl Checker { return Ok(CheckedDeclaration::Catalog { doc: declaration.doc.clone(), name: declaration.name.value.clone(), - r#type: to_checked_type(&produces), + // The stated state travels with the exported type, so a module + // that imports this name sees the same narrowing the declaring + // module did. Collection already computed it, so reading it back + // keeps one answer rather than two that could disagree. + r#type: to_checked_type( + self.values + .get(&declaration.name.value) + .unwrap_or(&produces), + ), sbol_identity, supplier_identity: supplier_identity .unwrap_or_else(|| declaration.name.value.clone()), @@ -1161,7 +1710,22 @@ impl Checker { generics: &BTreeSet, ) -> Result { match expression { - TypeExpr::Quantity { unit, .. } => Ok(Ty::Quantity(unit.clone())), + TypeExpr::Quantity { unit, span } => match unit { + Unit::Exact(unit) => Ok(Ty::Quantity(unit.clone())), + Unit::Dimension(dimension) => { + if crate::units::Dimension::named(dimension).is_none() { + return Err(SemanticError::new( + *span, + format!("'{dimension}' is not something this compiler measures"), + ) + .help( + "measurable things are Mass, Volume, Amount, Length, Duration, \ +Temperature, and Count", + )); + } + Ok(Ty::Measuring(dimension.clone())) + } + }, TypeExpr::Path { path, arguments, @@ -1217,6 +1781,13 @@ impl Checker { } Ok(Ty::Any(role_name)) } + TypeArgument::InState { + subject, state, .. + } => { + let subject = self.lower_type(subject, generics)?; + self.check_facet_state(&subject, state)?; + Ok(Ty::InState(Box::new(subject), state.value.clone())) + } }) .collect::, _>>()?; let expected_arity = self.standard_types.get(&name).map(|spec| spec.parameters); @@ -1263,6 +1834,7 @@ impl Checker { } Ok(match name.as_str() { "Integer" => Ty::Integer, + "Decimal" => Ty::Decimal, "String" => Ty::String, "Bool" => Ty::Bool, "None" => Ty::None, diff --git a/crates/lab-language/src/checker/expr.rs b/crates/lab-language/src/checker/expr.rs index 5565c220..ded2639b 100644 --- a/crates/lab-language/src/checker/expr.rs +++ b/crates/lab-language/src/checker/expr.rs @@ -8,6 +8,7 @@ use crate::semantic_error::SemanticError; use crate::source::Span; use crate::standard_library::ConstructorSpec; use crate::type_system::{Substitutions, Ty, substitute, to_checked_type}; +use crate::units; use super::Checker; use super::context::Generics; @@ -113,6 +114,26 @@ impl Checker { subject: Box::new(self.lower_checked_expr(subject, environment, None)?), field: field.value.clone(), }, + Expr::Convert { value, unit, span } => { + let inner = self.lower_checked_expr(value, environment, None)?; + let CheckedExpression::Quantity { + magnitude, + unit: from, + } = &inner.value + else { + return Err(SemanticError::new( + *span, + "only a measurement already known here converts", + ) + .help("convert the literal, or bind the converted value first")); + }; + let converted = converted_magnitude(magnitude, from, &unit.value) + .ok_or_else(|| inexact_conversion(magnitude, from, &unit.value, *span))?; + CheckedExpression::Quantity { + magnitude: converted, + unit: unit.value.clone(), + } + } Expr::Unary { op, operand, .. } => CheckedExpression::Unary { operator: match op { UnaryOp::Negate => "negate", @@ -123,11 +144,21 @@ impl Checker { }, Expr::Binary { op, left, right, .. - } => CheckedExpression::Binary { - operator: binary_operator_name(*op).to_owned(), - left: Box::new(self.lower_checked_expr(left, environment, None)?), - right: Box::new(self.lower_checked_expr(right, environment, None)?), - }, + } => { + let left = self.lower_checked_expr(left, environment, None)?; + let right = self.lower_checked_expr(right, environment, None)?; + // A recipe scaled to a batch is a measurement, not a sum waiting + // to be evaluated. Working it out here means a design states + // concentrations and a build reads grams. + match folded_quantity(&left.value, &right.value, *op) { + Some(folded) => folded, + None => CheckedExpression::Binary { + operator: binary_operator_name(*op).to_owned(), + left: Box::new(left), + right: Box::new(right), + }, + } + } }; Ok(TypedExpression { r#type: to_checked_type(ty), @@ -175,6 +206,16 @@ impl Checker { let subject = self.infer_expr(subject, environment)?; self.field_type(&subject, &field.value, *span) } + Expr::Convert { value, unit, span } => { + let measured = self.infer_expr(value, environment)?; + let Ty::Quantity(from) = &measured else { + return Err(SemanticError::new( + *span, + format!("{measured} is not a measurement, so it converts to nothing"), + )); + }; + convert_to(from, &unit.value, *span).map(Ty::Quantity) + } Expr::Unary { op, operand, span } => { let operand = self.infer_expr(operand, environment)?; match op { @@ -209,7 +250,15 @@ impl Checker { )) } } - BinaryOp::Equal | BinaryOp::NotEqual => Ok(Ty::Bool), + // Equality is otherwise permissive, but two measurements in + // different units are never equal and never unequal: the + // question cannot be asked until one is converted. + BinaryOp::Equal | BinaryOp::NotEqual => { + match mismatched_units(&left, &right, *span) { + Some(error) => Err(error), + None => Ok(Ty::Bool), + } + } BinaryOp::Less | BinaryOp::LessEqual | BinaryOp::Greater @@ -217,10 +266,12 @@ impl Checker { if self.comparable(&left, &right) { Ok(Ty::Bool) } else { - Err(SemanticError::new( - *span, - format!("cannot compare {left} with {right}"), - )) + Err(mismatched_units(&left, &right, *span).unwrap_or_else(|| { + SemanticError::new( + *span, + format!("cannot compare {left} with {right}"), + ) + })) } } BinaryOp::Add => match (&left, &right) { @@ -230,21 +281,46 @@ impl Checker { (Ty::List(left), Ty::EmptyList) => Ok(Ty::List(left.clone())), (Ty::EmptyList, Ty::List(right)) => Ok(Ty::List(right.clone())), _ if self.comparable(&left, &right) => Ok(left), - _ => Err(SemanticError::new( - *span, - format!("cannot add {left} and {right}"), - )), + _ => Err(mismatched_units(&left, &right, *span).unwrap_or_else(|| { + SemanticError::new(*span, format!("cannot add {left} and {right}")) + })), }, - BinaryOp::Subtract | BinaryOp::Multiply | BinaryOp::Divide => { + BinaryOp::Subtract => { if self.comparable(&left, &right) { Ok(left) } else { - Err(SemanticError::new( - *span, - "incompatible arithmetic operands", - )) + Err(mismatched_units(&left, &right, *span).unwrap_or_else(|| { + SemanticError::new( + *span, + format!("cannot subtract {right} from {left}"), + ) + })) } } + // Scaling a measurement by a count keeps its unit, which is + // how a recipe states a batch. Multiplying two measurements + // is a different operation: what comes out measures + // something neither operand measured, and lands in the + // canonical unit of whatever that is. + BinaryOp::Multiply | BinaryOp::Divide => match (&left, &right) { + (Ty::Quantity(_), other) if crate::type_system::dimensionless(other) => { + Ok(left) + } + (other, Ty::Quantity(_)) + if crate::type_system::dimensionless(other) + && matches!(op, BinaryOp::Multiply) => + { + Ok(right) + } + (Ty::Quantity(left_unit), Ty::Quantity(right_unit)) => { + composed_unit(left_unit, right_unit, *op, *span) + } + _ if self.comparable(&left, &right) => Ok(left), + _ => Err(SemanticError::new( + *span, + format!("cannot combine {left} with {right} arithmetically"), + )), + }, BinaryOp::Range => Ok(Ty::List(Box::new(left))), } } @@ -692,6 +768,169 @@ pub(super) fn numeric_text(expression: &Expr) -> Result { } } +/// A product or quotient of two measurements already known here, worked out. +/// +/// The result lands in the canonical unit of what it measures, so `10 g/L * 500 +/// mL` is `5 g`. Anything whose operands are not both known, or whose quotient +/// does not terminate, is left as it was written for a later pass to refuse or +/// evaluate. +fn folded_quantity( + left: &CheckedExpression, + right: &CheckedExpression, + op: BinaryOp, +) -> Option { + if !matches!(op, BinaryOp::Multiply | BinaryOp::Divide) { + return None; + } + let scale = |value: &CheckedExpression| match value { + CheckedExpression::Integer { value } => { + Some((units::Decimal::parse(&value.to_string())?, None)) + } + CheckedExpression::Decimal { text } => Some((units::Decimal::parse(text)?, None)), + CheckedExpression::Quantity { magnitude, unit } => { + Some((units::Decimal::parse(magnitude)?, Some(unit.clone()))) + } + _ => None, + }; + let (left_magnitude, left_unit) = scale(left)?; + let (right_magnitude, right_unit) = scale(right)?; + let magnitude = match op { + BinaryOp::Multiply => left_magnitude.times(right_magnitude)?, + _ => left_magnitude.over(right_magnitude)?, + }; + match (left_unit, right_unit) { + // Scaling by a count keeps the unit and the magnitude it scaled. + (Some(unit), None) => Some(CheckedExpression::Quantity { + magnitude: magnitude.to_string(), + unit, + }), + // A count divided by a measurement is a rate nobody wrote, so only + // multiplication scales in this direction. + (None, Some(unit)) => { + matches!(op, BinaryOp::Multiply).then(|| CheckedExpression::Quantity { + magnitude: magnitude.to_string(), + unit, + }) + } + (Some(left), Some(right)) => { + let (source, target) = (units::measured(&left)?, units::measured(&right)?); + let dimension = match op { + BinaryOp::Multiply => source.dimension.times(target.dimension), + _ => source.dimension.over(target.dimension), + }; + let decades = match op { + BinaryOp::Multiply => source.decade + target.decade, + _ => source.decade - target.decade, + }; + let magnitude = magnitude.shifted(decades)?.to_string(); + if dimension.is_dimensionless() { + return Some(CheckedExpression::Decimal { text: magnitude }); + } + Some(CheckedExpression::Quantity { + magnitude, + unit: units::canonical(dimension)?, + }) + } + (None, None) => None, + } +} + +/// The unit a conversion lands in, when the two measure the same thing. +fn convert_to(from: &str, to: &str, span: Span) -> Result { + if units::ratio(from, to).is_some() { + return Ok(to.to_owned()); + } + let measures = |unit: &str| units::measured(unit).map(|it| it.dimension); + Err(match (measures(from), measures(to)) { + (Some(source), Some(target)) => SemanticError::new( + span, + format!("'{from}' and '{to}' do not measure the same thing"), + ) + .help(format!( + "'{from}' measures {source} and '{to}' measures {target}" + )), + _ => SemanticError::new( + span, + format!("'{from}' and '{to}' are not both units this compiler knows"), + ) + .help("a measurement converts only where both units say what they measure"), + }) +} + +/// This magnitude written in another unit, when it converts exactly. +fn converted_magnitude(magnitude: &str, from: &str, to: &str) -> Option { + let (numerator, denominator) = units::ratio(from, to)?; + Some( + units::Decimal::parse(magnitude)? + .scaled(numerator, denominator)? + .to_string(), + ) +} + +fn inexact_conversion(magnitude: &str, from: &str, to: &str, span: Span) -> SemanticError { + SemanticError::new( + span, + format!("{magnitude} {from} has no exact value in '{to}'"), + ) + .help("a measurement that cannot be converted exactly is rounded, and a rounded quantity is weighed out wrong") +} + +/// What a product or a quotient of two measurements measures. +/// +/// The result lands in the canonical unit of whatever it came out measuring, so +/// `10 g/L * 500 mL` is grams rather than some scaled unit nobody wrote. Two +/// measurements whose product measures nothing nameable, or either of which this +/// compiler has no opinion about, have no answer worth guessing at. +fn composed_unit(left: &str, right: &str, op: BinaryOp, span: Span) -> Result { + let unknown = |unit: &str| { + SemanticError::new(span, format!("'{unit}' is not a unit this compiler knows")) + .help("a measurement composes with another only where both say what they measure") + }; + let Some(source) = units::measured(left) else { + return Err(unknown(left)); + }; + let Some(target) = units::measured(right) else { + return Err(unknown(right)); + }; + let dimension = match op { + BinaryOp::Multiply => source.dimension.times(target.dimension), + _ => source.dimension.over(target.dimension), + }; + if dimension.is_dimensionless() { + return Ok(Ty::Decimal); + } + units::canonical(dimension) + .map(Ty::Quantity) + .ok_or_else(|| { + SemanticError::new( + span, + format!( + "'{left}' and '{right}' compose into a measurement with no unit to write it in" + ), + ) + .help(format!("the result measures {dimension}")) + }) +} + +/// The diagnostic for two measurements that meet in a unit neither shares. +/// +/// This is the same mistake as writing `20 mL` where microlitres are required, +/// so it reads the same way: name both units and say that conversion is written +/// rather than assumed. Returns `None` when the operands are not both +/// quantities, so the caller keeps its own wording. +fn mismatched_units(left: &Ty, right: &Ty, span: Span) -> Option { + let (Ty::Quantity(left_unit), Ty::Quantity(right_unit)) = (left, right) else { + return None; + }; + Some( + SemanticError::new(span, format!("'{left_unit}' and '{right_unit}' are different units")) + .help(format!( + "a measurement in '{left_unit}' and one in '{right_unit}' are not interchangeable, so neither converts on its own" + )) + .help("write both operands in the same unit"), + ) +} + pub(super) fn binary_operator_name(operator: BinaryOp) -> &'static str { match operator { BinaryOp::Or => "or", diff --git a/crates/lab-language/src/checker/interface.rs b/crates/lab-language/src/checker/interface.rs index 68844302..0fa80815 100644 --- a/crates/lab-language/src/checker/interface.rs +++ b/crates/lab-language/src/checker/interface.rs @@ -4,8 +4,8 @@ use std::collections::BTreeMap; use crate::checked::{CheckedDeclaration, CheckedType}; use crate::semantics::{ - CallableSignature, DefinitionId, ExportKind, ModuleExport, ModuleId, ModuleInterface, - TypeParameters, + ActionSurface, CallableSignature, DefinitionId, ExportKind, FacetSurface, ModuleExport, + ModuleId, ModuleInterface, TypeParameters, }; pub(super) fn build_interface( @@ -33,6 +33,8 @@ pub(super) fn build_interface( roles: Vec::new(), term: None, schema: None, + facet: None, + action: None, parameters: TypeParameters::default(), documentation: documentation.clone().unwrap_or_default(), }, @@ -74,6 +76,62 @@ pub(super) fn build_interface( BTreeMap::new(), doc, ), + CheckedDeclaration::Action { + doc, + name, + operation, + phrase, + operands, + results, + capability, + } => { + insert( + &mut interface, + name, + ExportKind::Action, + None, + None, + BTreeMap::new(), + doc, + ); + interface + .exports + .get_mut(name) + .expect("the action export was just inserted") + .action = Some(ActionSurface { + operation: operation.clone(), + phrase: phrase.clone(), + operands: operands.clone(), + results: results.clone(), + capability: capability.clone(), + }); + } + CheckedDeclaration::Facet { + doc, + name, + subject, + states, + transitions, + } => { + insert( + &mut interface, + name, + ExportKind::Facet, + None, + None, + BTreeMap::new(), + doc, + ); + interface + .exports + .get_mut(name) + .expect("the facet export was just inserted") + .facet = Some(FacetSurface { + subject: subject.clone(), + states: states.clone(), + transitions: transitions.clone(), + }); + } CheckedDeclaration::Role { doc, name, term } => { insert( &mut interface, diff --git a/crates/lab-language/src/checker/mod.rs b/crates/lab-language/src/checker/mod.rs index bc0d1726..071a94e6 100644 --- a/crates/lab-language/src/checker/mod.rs +++ b/crates/lab-language/src/checker/mod.rs @@ -79,6 +79,57 @@ impl Checker { term: self.role_terms.get(&declaration.name.value).cloned(), }); } + Item::Facet(declaration) => { + let signature = self + .facets + .get(&declaration.name.value) + .expect("the facet was collected"); + declarations.push(CheckedDeclaration::Facet { + doc: declaration.doc.clone(), + name: declaration.name.value.clone(), + subject: to_checked_type(&signature.subject), + states: signature + .states + .iter() + .map(|state| CheckedFacetState { + doc: state.doc.clone(), + name: state.name.clone(), + fields: state + .fields + .iter() + .map(|(name, field)| CheckedSchemaField { + name: name.clone(), + r#type: to_checked_type(&field.ty), + optional: field.optional, + }) + .collect(), + }) + .collect(), + transitions: signature + .transitions + .iter() + .map(|(from, to)| CheckedFacetTransition { + from: from.clone(), + to: to.clone(), + }) + .collect(), + }); + } + Item::Action(declaration) => { + let contract = self + .action_contracts + .get(&declaration.name.value) + .expect("the action was collected"); + declarations.push(CheckedDeclaration::Action { + doc: declaration.doc.clone(), + name: declaration.name.value.clone(), + operation: contract.operation.clone(), + phrase: contract.phrase.clone(), + operands: contract.operands.clone(), + results: contract.results.clone(), + capability: contract.capability.clone(), + }); + } Item::ArtifactKind(declaration) => { let signature = self .artifact_kinds @@ -727,21 +778,23 @@ plasmid reporter_region: #[test] fn checks_named_workflow_results_and_multi_result_calls() { let module = compile_module( - r#"workflow preserve( + r#"use std.bio.designs + +workflow preserve( product: Material, - plate: Material, + plate: Material, ) -> ( product: Material, - plate: Material, + plate: Material, ): return product, plate workflow delegate( product: Material, - plate: Material, + plate: Material, ) -> ( product: Material, - plate: Material, + plate: Material, ): preserved_product, preserved_plate <- preserve product plate return preserved_product, preserved_plate @@ -1970,7 +2023,7 @@ workflow preserve(plasmid: Material) -> Material: let CheckedStatement::Effect { action, .. } = &body[0] else { panic!("expected effect") }; - assert_eq!(module.schema_version, "lab.portable-module.v9"); + assert_eq!(module.schema_version, "lab.portable-module.v11"); assert_eq!(action.operation, "std.lab.plasmid.store"); assert_eq!(action.arguments[0].mode, OwnershipMode::Take); assert_eq!(action.results[0].name, "material"); @@ -2390,6 +2443,225 @@ buy reagent BsaI: ); } + /// A measurement composes with another, and the result measures something + /// neither operand measured. That is what lets a recipe state concentrations + /// once and scale to whatever batch is being made. + mod dimensions { + use super::*; + + const SCHEMA: &str = r#"record Recipe + +artifact Recipe: + tryptone?: Quantity + salt?: Quantity + buffer?: Quantity + either?: Quantity | Quantity + bulk?: Quantity + length?: Quantity + volume?: Quantity
    + +"#; + + fn stated(body: &str) -> Result { + let module = compile_module(&format!("{SCHEMA}build recipe LB:\n{body}"))?; + let declaration = module + .declarations + .iter() + .find_map(|declaration| match declaration { + CheckedDeclaration::Artifact { properties, .. } => properties.first(), + _ => None, + }) + .expect("the property was checked"); + let CheckedExpression::Quantity { magnitude, unit } = &declaration.value.value else { + panic!("a composed measurement is a measurement"); + }; + Ok(format!("{magnitude} {unit}")) + } + + /// The headline: a recipe holds concentrations and a batch is a volume, + /// so what to weigh out is their product. + #[test] + fn a_recipe_scales_to_a_batch() { + assert_eq!( + stated(" tryptone = 10 g/L * 500 mL\n").expect("a recipe scales"), + "5 g" + ); + } + + /// A concentration divides into a mass to give the volume holding it, + /// which is the arithmetic behind every dilution done at a bench. + #[test] + fn a_mass_over_a_concentration_is_a_volume() { + assert_eq!( + stated(" volume = (500 ng / 100 ng/uL) in uL\n").expect("a dilution computes"), + "5 uL" + ); + } + + /// The result lands in the canonical unit of what it measures, so it is + /// predictable rather than inherited from whichever operand came first. + #[test] + fn a_composed_measurement_lands_in_a_canonical_unit() { + assert_eq!( + stated(" bulk = 2 mg * 3\n").expect("scaling keeps its unit"), + "6 mg" + ); + } + + /// Conversion is written. `12 kb` is what a person means and `12000 bp` + /// is what the field holds, and saying so is one word. + #[test] + fn a_measurement_converts_where_it_is_written() { + assert_eq!( + stated(" length = 12 kb in bp\n").expect("kilobases are base pairs"), + "12000 bp" + ); + } + + #[test] + fn refuses_converting_between_different_things() { + let error = stated(" length = 12 kb in uL\n").unwrap_err().to_string(); + assert!( + error.contains("do not measure the same thing"), + "a length is not a volume: {error}" + ); + } + + /// A field naming a dimension takes any unit of it, so a recipe holds + /// milligrams per litre beside grams per litre without pinning either. + /// + /// Mass in a volume and amount in a volume stay different things: going + /// between them needs a molar mass, which is a fact about the substance + /// and not about the recipe. A field that holds either says so. + #[test] + fn a_field_may_ask_for_a_dimension_rather_than_a_unit() { + compile_module(&format!("{SCHEMA}build recipe LB:\n salt = 10 g/L\n")) + .expect("grams per litre is a concentration"); + compile_module(&format!("{SCHEMA}build recipe TE:\n buffer = 50 mM\n")) + .expect("millimolar is a molarity"); + compile_module(&format!("{SCHEMA}build recipe TE:\n either = 50 mM\n")) + .expect("a field holding either takes both"); + let error = compile_module(&format!("{SCHEMA}build recipe TE:\n salt = 50 mM\n")) + .unwrap_err() + .to_string(); + assert!( + error.contains("expects Quantity"), + "mass in a volume is not amount in a volume: {error}" + ); + + let error = compile_module(&format!("{SCHEMA}build recipe LB:\n bulk = 5 mL\n")) + .unwrap_err() + .to_string(); + assert!( + error.contains("expects Quantity"), + "a volume is not a mass: {error}" + ); + } + + #[test] + fn refuses_a_dimension_this_compiler_does_not_measure() { + let error = compile_module( + "record R\n\nartifact R:\n x?: Quantity\n\nbuild r a:\n x = 1 cd\n", + ) + .unwrap_err() + .to_string(); + assert!( + error.contains("'Luminosity' is not something this compiler measures"), + "the diagnostic names what it does measure: {error}" + ); + } + } + + /// An assignment refused microlitres against millilitres while arithmetic + /// and comparison let the same two units meet freely. Both halves of the + /// language now hold the unit to the same standard. + mod quantity_arithmetic { + use super::*; + + fn body(body: &str) -> Result { + compile_module(&format!( + "artifact Plasmid:\n a?: Quantity
      \n\nplasmid p:\n{body}" + )) + } + + fn refuses(source: &str) -> String { + body(source) + .expect_err("two units that are not the same unit cannot meet") + .to_string() + } + + #[test] + fn measurements_in_one_unit_combine() { + body(" a = 20 uL + 5 uL\n").expect("microlitres add to microlitres"); + body(" a = 20 uL - 5 uL\n").expect("microlitres subtract from microlitres"); + body(" a = 20 uL\n require a > 5 uL\n").expect("one unit compares with itself"); + } + + /// Scaling a measurement by a count is how a recipe states a batch, and + /// it keeps the unit it started in. + #[test] + fn a_measurement_scales_by_a_plain_number() { + body(" a = 20 uL * 3\n").expect("a volume times a count is a volume"); + body(" a = 20 uL / 2\n").expect("a volume divided by a count is a volume"); + } + + /// A slash after a unit reads as a denominator, which made a quantity + /// impossible to divide. A denominator is a unit, so anything else is + /// division. + #[test] + fn a_compound_unit_still_reads_as_one_unit() { + compile_module( + "record Reagent:\n concentration: Quantity\n\nartifact Reagent\n\nbuy reagent stock:\n concentration = 100 ng/uL\n", + ) + .expect("a compound unit is not a division"); + } + + #[test] + fn refuses_two_units_meeting_in_arithmetic() { + for source in [" a = 20 uL + 5 mL\n", " a = 20 uL - 5 mL\n"] { + let error = refuses(source); + assert!( + error.contains("'uL' and 'mL' are different units"), + "the diagnostic names both units: {error}" + ); + } + } + + #[test] + fn refuses_two_units_meeting_in_a_comparison() { + for source in [ + " a = 20 uL\n require a > 5 mL\n", + " a = 20 uL\n require a == 5 mL\n", + ] { + let error = refuses(source); + assert!( + error.contains("'uL' and 'mL' are different units"), + "equality is no more askable across units than ordering is: {error}" + ); + } + } + + /// Two measurements multiplied give a quantity in neither operand's + /// unit. A volume times a volume measures something a laboratory has no + /// unit for, so there is nothing to write the answer in. + #[test] + fn refuses_a_product_with_no_unit_to_write_it_in() { + let error = refuses(" a = 20 uL * 5 uL\n"); + assert!( + error.contains("no unit to write it in"), + "a volume times a volume is not a volume: {error}" + ); + } + + /// A measurement divided by one measuring the same thing is a plain + /// ratio, which is how a dilution factor is written. + #[test] + fn one_measurement_over_another_of_the_same_thing_is_a_number() { + body(" a = 20 uL\n require (100 uL / 20 uL) > 4.0\n") + .expect("a ratio of volumes is a number"); + } + } + #[test] fn requires_every_field_a_schema_does_not_mark_optional() { let error = compile_module( @@ -2467,6 +2739,137 @@ plasmid sample: .expect("a rule reads the produced type's field"); } + /// A package declares a durable verb with `action`, and a workflow checks + /// against it the way it checks a bundled one. A new verb is a declaration. + mod actions { + use super::*; + + const CENTRIFUGE: &str = r#"use std.bio.designs +use std.lab.plasmid + +action centrifuge at for -> pellet: + culture: take Material + force: Quantity + duration: Quantity + pellet: Material + requires Centrifugation + +buy chassis DH5alpha: + competence = competent + efficiency = 1e9 cfu/ug + +buy plasmid p: + sbol_identity = "https://example.org/p" + sequence = dna("ACGT") + +build strain s: + chassis = DH5alpha + plasmids = [p] + +workflow spin(dna: List>) -> Material: + cells <- provision DH5alpha + strain, culture <- transform s from dna into cells + culture <- recover culture for 1 h + pellet <- centrifuge culture at 4000 rcf for 10 min + <- dispose strain + return pellet +"#; + + #[test] + fn a_declared_verb_checks_in_a_workflow() { + let module = compile_module(CENTRIFUGE).expect("a declared verb is usable"); + let action = module + .declarations + .iter() + .find_map(|declaration| match declaration { + CheckedDeclaration::Action { + name, + operation, + capability, + .. + } => Some((name.clone(), operation.clone(), capability.clone())), + _ => None, + }); + let (name, operation, capability) = action.expect("the action was checked"); + assert_eq!(name, "centrifuge"); + assert_eq!(operation, "standalone.centrifuge"); + assert_eq!(capability, "Centrifugation"); + } + + /// The phrase is checked as written: a wrong word or a missing operand + /// is a diagnostic against the declared shape. + #[test] + fn refuses_a_call_that_does_not_match_the_phrase() { + let wrong = CENTRIFUGE.replace( + "centrifuge culture at 4000 rcf for 10 min", + "centrifuge culture at 4000 rcf", + ); + let error = compile_module(&wrong).unwrap_err().to_string(); + assert!( + error.contains("centrifuge"), + "the phrase is checked against the declaration: {error}" + ); + } + + /// A measurement operand pins its unit, so calling with another is the + /// same thousandfold refusal a field gets. + #[test] + fn a_measurement_operand_checks_its_unit() { + let wrong = CENTRIFUGE.replace("at 4000 rcf", "at 4000 g"); + let error = compile_module(&wrong).unwrap_err().to_string(); + assert!( + error.contains("rcf"), + "the operand's unit is what it accepts: {error}" + ); + } + + /// A verb an importing module uses is checked against the same contract + /// the declaring module wrote. + #[test] + fn a_verb_crosses_a_module_boundary() { + let verbs = "use std.bio.designs + +action chill for -> chilled: + culture: take Material + duration: Quantity + chilled: Material + requires StaticIncubation +"; + let designs = + compile_module_with_id(ModuleId::new("pkg.verbs"), verbs).expect("verbs compile"); + let mut environment = SemanticEnvironment::default(); + environment.insert("pkg.verbs", designs.interface.clone()); + compile_module_in_environment( + ModuleId::new("pkg.work"), + "use std.bio.designs +use std.lab.plasmid +use pkg.verbs + +workflow w(c: Material) -> Material: + c <- chill c for 20 min + return c +", + &environment, + ) + .expect("an imported verb checks a workflow"); + } + + #[test] + fn an_action_states_the_capability_it_needs() { + let error = compile_module( + "action spin -> c: + c: take Material +", + ) + .unwrap_err() + .to_string(); + assert!( + error.contains("capability"), + "a verb without a capability cannot be allocated: {error}" + ); + } + } + #[test] fn rejects_a_completeness_rule_naming_a_required_field() { let error = compile_module( @@ -2486,4 +2889,323 @@ plasmid sample: "requiredness and a completeness rule must not disagree: {error}" ); } + + /// A facet classifies a kind's materials by the state they are in, which is + /// what keeps a state off the type. `Culture` and `Plate` were types for + /// want of this, and neither could name the design underneath it. + mod facets { + use super::*; + use crate::ExportKind; + + const COMPETENCE: &str = r#"artifact Chassis: + label?: String + +facet Competence on Chassis: + /** Cells as they come off an overnight culture. */ + naive + /** Cells a transformation may be attempted in. */ + competent: + efficiency: Quantity + + naive -> competent +"#; + + fn facet(module: &CheckedModule) -> (&CheckedType, &[CheckedFacetState]) { + module + .declarations + .iter() + .find_map(|declaration| match declaration { + CheckedDeclaration::Facet { + subject, states, .. + } => Some((subject, states.as_slice())), + _ => None, + }) + .expect("the facet was checked") + } + + #[test] + fn a_facet_carries_its_subject_states_and_transitions() { + let module = compile_module(COMPETENCE).expect("a facet over a declared kind checks"); + let (subject, states) = facet(&module); + assert_eq!(subject.display_name(), "Chassis"); + assert_eq!( + states + .iter() + .map(|state| state.name.as_str()) + .collect::>(), + ["naive", "competent"], + "declaration order is preserved, so the first state stays identifiable" + ); + assert!(states[0].fields.is_empty()); + assert_eq!(states[1].fields[0].name, "efficiency"); + assert_eq!( + states[1].fields[0].r#type.display_name(), + "Quantity" + ); + assert_eq!( + states[1].doc.as_deref(), + Some("Cells a transformation may be attempted in."), + "a state documents itself, so the reference cannot drift from it" + ); + } + + /// A facet is part of a module's public surface. An importer that cannot + /// see the states cannot constrain a material to one. + #[test] + fn a_facet_is_exported_with_its_states() { + let module = compile_module(COMPETENCE).expect("checks"); + let export = module + .interface + .exports + .get("Competence") + .expect("the facet is exported"); + assert_eq!(export.kind, ExportKind::Facet); + let surface = export.facet.as_ref().expect("the states travel with it"); + assert_eq!(surface.subject.display_name(), "Chassis"); + assert_eq!(surface.states.len(), 2); + assert_eq!(surface.transitions.len(), 1); + assert_eq!(surface.transitions[0].from, "naive"); + assert_eq!(surface.transitions[0].to, "competent"); + } + + #[test] + fn rejects_a_transition_naming_a_state_the_facet_has_not_declared() { + let error = compile_module( + "artifact Chassis\n\nfacet Competence on Chassis:\n naive\n competent\n\n naive -> transformed\n", + ) + .unwrap_err(); + let message = error.to_string(); + assert!( + message.contains("facet 'Competence' has no state 'transformed'"), + "the diagnostic names the facet and the missing state: {error}" + ); + } + + /// A state nothing reaches cannot be established, so the kind is making a + /// claim it cannot honor. The first state needs no transition into it + /// because that is where a material starts. + #[test] + fn rejects_a_state_no_transition_reaches() { + let error = compile_module( + "artifact Chassis\n\nfacet Competence on Chassis:\n naive\n competent\n", + ) + .unwrap_err(); + let message = error.to_string(); + assert!( + message.contains("no transition reaches state 'competent'"), + "an unreachable state is refused at its declaration: {error}" + ); + } + + #[test] + fn rejects_a_duplicate_state() { + let error = compile_module( + "artifact Chassis\n\nfacet Competence on Chassis:\n naive\n naive\n", + ) + .unwrap_err(); + assert!( + error.to_string().contains("duplicate state 'naive'"), + "each state a facet admits is listed once: {error}" + ); + } + + /// Several facets may classify one kind and they stay independent. A + /// culture that is both diluted and grown under selection is two facets, + /// not one state naming both. + #[test] + fn a_kind_carries_several_independent_facets() { + let module = compile_module( + r#"record Broth + +artifact Broth + +facet Dilution on Broth: + neat + diluted + + neat -> diluted + +facet Selection on Broth: + permissive + selective + + permissive -> selective +"#, + ) + .expect("two facets over one kind check"); + let facets = module + .declarations + .iter() + .filter(|declaration| matches!(declaration, CheckedDeclaration::Facet { .. })) + .count(); + assert_eq!(facets, 2); + } + + /// A facet resolves after every kind in the file, so a module reads with + /// the thing first and the states it may be in after. + #[test] + fn a_facet_may_be_declared_above_the_kind_it_classifies() { + compile_module( + "facet Competence on Chassis:\n naive\n competent\n\n naive -> competent\n\nartifact Chassis\n", + ) + .expect("declaration order does not decide whether a facet resolves"); + } + + /// The first state is where a material starts, so it needs no transition + /// into it and a facet naming only that state is complete. + #[test] + fn an_initial_state_needs_no_transition_into_it() { + compile_module("artifact Chassis\n\nfacet Competence on Chassis:\n naive\n") + .expect("the state a material starts in is reachable by definition"); + } + + #[test] + fn rejects_a_facet_on_something_that_is_not_a_kind() { + let error = compile_module("facet Competence on Widget:\n naive\n").unwrap_err(); + assert!( + error + .to_string() + .contains("'Widget' is not a kind in scope"), + "a facet classifies a kind that exists: {error}" + ); + } + + /// A material narrowed to a state is written `Material`. The state constrains the argument rather than wrapping + /// the material, so ownership analysis still sees a material. + mod narrowing { + use super::*; + + const BASE: &str = r#"use std.lab.plasmid + +artifact Chassis + +facet Competence on Chassis: + naive + competent: + efficiency: Quantity + + naive -> competent +"#; + + fn check(tail: &str) -> Result { + compile_module(&format!("{BASE}\n{tail}")) + } + + /// Knowing which state a material is in is never a problem where any + /// state is accepted. The reverse is what narrowing exists to + /// refuse. + #[test] + fn narrowing_runs_one_way() { + check("workflow w(c: Material) -> Material:\n return c\n") + .expect("a competent chassis is a chassis"); + + let error = + check("workflow w(c: Material) -> Material:\n return c\n") + .expect_err("a chassis is not known to be competent"); + assert!( + error + .to_string() + .contains("expected Material"), + "the diagnostic shows the state that was required: {error}" + ); + } + + #[test] + fn refuses_one_state_where_another_is_required() { + let error = check( + "workflow w(c: Material) -> Material:\n return c\n", + ) + .expect_err("naive cells are not competent cells"); + assert!( + error.to_string().contains("Material"), + "the diagnostic shows the state that was offered: {error}" + ); + } + + /// The encoding matters. Wrapping the material instead of its + /// argument would take it out of ownership analysis, which finds a + /// material by its outermost name. + #[test] + fn a_narrowed_material_is_still_affine() { + let error = check( + "workflow w(c: Material) -> None:\n <- dispose c\n <- dispose c\n return None\n", + ) + .expect_err("narrowing a material does not launder its ownership"); + assert!( + error.to_string().contains("'c' is no longer available"), + "a narrowed material is consumed exactly like any other: {error}" + ); + } + + #[test] + fn rejects_a_state_no_facet_admits() { + let error = + check("workflow w(c: Material) -> None:\n <- dispose c\n return None\n") + .expect_err("a state has to be one the kind declares"); + let message = error.to_string(); + assert!( + message.contains("'Chassis' has no state 'transformed'"), + "the diagnostic names the kind and the state: {error}" + ); + } + + /// The whole point, end to end. `transform` requires competent + /// cells; a chassis carries the state its declaration states; so + /// transforming into cells nobody made competent is a diagnostic at + /// the operand rather than a silent success. + #[test] + fn transformation_requires_cells_that_were_made_competent() { + const PROGRAM: &str = r#"use std.bio.designs +use std.lab.plasmid + +buy chassis DH5alpha: + competence = competent + efficiency = 1e9 cfu/ug + +buy chassis Naive: + competence = naive + +buy plasmid p: + sbol_identity = "https://example.org/p" + sequence = dna("ACGT") + +build strain s: + chassis = DH5alpha + plasmids = [p] + +workflow build(dna: List>) -> Material: + cells <- provision {host} + strain, culture <- transform s from dna into cells + <- dispose culture + return strain +"#; + compile_module(&PROGRAM.replace("{host}", "DH5alpha")) + .expect("cells declared competent may be transformed"); + + let error = compile_module(&PROGRAM.replace("{host}", "Naive")) + .expect_err("naive cells take up nothing"); + assert!( + error + .to_string() + .contains("expects Material"), + "the operand names the state it required: {error}" + ); + } + + #[test] + fn rejects_narrowing_a_kind_no_facet_classifies() { + let error = compile_module( + "use std.lab.plasmid\n\nrecord Widget\n\nartifact Widget\n\nworkflow w(c: Material) -> None:\n <- dispose c\n return None\n", + ) + .expect_err("a kind with no facet has no states to narrow to"); + assert!( + error + .to_string() + .contains("'Widget' has no state 'competent'"), + "the diagnostic names the kind: {error}" + ); + } + } + } } diff --git a/crates/lab-language/src/checker/workflow.rs b/crates/lab-language/src/checker/workflow.rs index b6ffbdf2..d35cc225 100644 --- a/crates/lab-language/src/checker/workflow.rs +++ b/crates/lab-language/src/checker/workflow.rs @@ -458,7 +458,17 @@ impl Checker { .copied() .ok_or_else(|| SemanticError::new(effect.span, "empty effect action"))?; if let Some(contract) = self.actions.get(operation).cloned() { - return self.check_standard_action_contract(effect, &words, environment, contract); + let (mut action, types) = + self.check_standard_action_contract(effect, &words, environment, contract)?; + // A declared verb states the capability it needs; the call carries + // it so the compiler can derive a method that requires it. The six + // bundled verbs are not in this map and keep their capability in + // their own lowering. + action.capability = self + .action_contracts + .get(operation) + .map(|contract| contract.capability.clone()); + return Ok((action, types)); } let providers = self.standard_library.action_providers(operation); if let [required_module] = providers.as_slice() { @@ -531,6 +541,7 @@ impl Checker { ResolvedAction { operation: format!("workflow.{operation}"), callee: Some(self.definition_for_action_word(operation)), + capability: None, arguments, results: outputs .iter() diff --git a/crates/lab-language/src/lexer.rs b/crates/lab-language/src/lexer.rs index 262ef706..b0d1aba3 100644 --- a/crates/lab-language/src/lexer.rs +++ b/crates/lab-language/src/lexer.rs @@ -304,16 +304,28 @@ impl<'a> Lexer<'a> { while self.bytes.get(self.cursor).is_some_and(u8::is_ascii_digit) { self.cursor += 1; } + let mut fractional = false; if self.bytes.get(self.cursor) == Some(&b'.') && self .bytes .get(self.cursor + 1) .is_some_and(u8::is_ascii_digit) { + fractional = true; self.cursor += 1; while self.bytes.get(self.cursor).is_some_and(u8::is_ascii_digit) { self.cursor += 1; } + } + // A transformation efficiency is 1e9 cfu/ug and a copy number is 2e5. + // Writing those out is a row of zeros to miscount, which is the class + // of error a measured language exists to refuse. + if self.lex_exponent() { + let magnitude = expanded_exponent(&self.source[start..self.cursor]); + self.push(TokenKind::Decimal(magnitude), start, self.cursor); + return Ok(()); + } + if fractional { self.push( TokenKind::Decimal(self.source[start..self.cursor].to_owned()), start, @@ -328,6 +340,29 @@ impl<'a> Lexer<'a> { Ok(()) } + /// Consume an `e12` or `e-3` suffix, reporting whether one was there. + /// + /// `e` is only an exponent when digits follow it, optionally after a sign. + /// Otherwise it opens a unit, and `20 eq` has to keep meaning twenty of + /// whatever `eq` is. + fn lex_exponent(&mut self) -> bool { + if !matches!(self.bytes.get(self.cursor), Some(b'e' | b'E')) { + return false; + } + let mut lookahead = self.cursor + 1; + if matches!(self.bytes.get(lookahead), Some(b'+' | b'-')) { + lookahead += 1; + } + if !self.bytes.get(lookahead).is_some_and(u8::is_ascii_digit) { + return false; + } + self.cursor = lookahead; + while self.bytes.get(self.cursor).is_some_and(u8::is_ascii_digit) { + self.cursor += 1; + } + true + } + fn lex_identifier(&mut self) { let start = self.cursor; self.cursor += 1; @@ -382,6 +417,30 @@ impl<'a> Lexer<'a> { /// The prose inside a `/** ... */`, with the decoration a reader supplies for /// alignment removed: the leading `*` of a continuation line, trailing spaces, /// and blank lines at either end. Blank lines between paragraphs are kept. +/// `1e9` written out as `1000000000`, and `2e-3` as `0.002`. +/// +/// An exponent is a way of writing a number, not a different kind of number, so +/// it is expanded where it is read. Every later pass then sees one decimal +/// spelling and none of them has to learn a second one. +fn expanded_exponent(literal: &str) -> String { + let (mantissa, exponent) = literal + .split_once(['e', 'E']) + .expect("an exponent literal carries its marker"); + let exponent: i64 = exponent.parse().expect("the exponent is a signed integer"); + let (whole, fraction) = mantissa.split_once('.').unwrap_or((mantissa, "")); + let digits = format!("{whole}{fraction}"); + // Where the point sits once the exponent has moved it. + let point = whole.len() as i64 + exponent; + if point <= 0 { + return format!("0.{}{digits}", "0".repeat(point.unsigned_abs() as usize)); + } + let point = point as usize; + if point >= digits.len() { + return format!("{digits}{}", "0".repeat(point - digits.len())); + } + format!("{}.{}", &digits[..point], &digits[point..]) +} + fn documentation(text: &str) -> String { let mut lines: Vec<&str> = text .lines() diff --git a/crates/lab-language/src/lib.rs b/crates/lab-language/src/lib.rs index f24b1c31..75decc42 100644 --- a/crates/lab-language/src/lib.rs +++ b/crates/lab-language/src/lib.rs @@ -16,6 +16,7 @@ mod source; mod standard_library; mod token; mod type_system; +mod units; pub use checked::{ CheckedActionArgument, CheckedArgument, CheckedBinding, CheckedCase, CheckedDeclaration, diff --git a/crates/lab-language/src/parser.rs b/crates/lab-language/src/parser.rs index 4f7bdf44..ef75a561 100644 --- a/crates/lab-language/src/parser.rs +++ b/crates/lab-language/src/parser.rs @@ -1,4 +1,5 @@ use crate::ast::*; +use crate::checked::OwnershipMode; use crate::error::{ParseError, syntax_span}; use crate::lexer::lex; use crate::source::{Identifier, Span, Spanned}; @@ -61,6 +62,14 @@ impl<'a> Parser<'a> { let mut declaration = self.parse_role()?; declaration.doc = doc; Item::Role(declaration) + } else if self.check_word("facet") { + let mut declaration = self.parse_facet()?; + declaration.doc = doc; + Item::Facet(declaration) + } else if self.check_word("action") { + let mut declaration = self.parse_action()?; + declaration.doc = doc; + Item::Action(declaration) } else if self.check_word("circuit") { let mut declaration = self.parse_circuit()?; declaration.doc = doc; @@ -147,6 +156,161 @@ impl<'a> Parser<'a> { }) } + /// `facet Competence on Chassis:` — the states a kind's materials may be in. + /// + /// A line inside the block is a state, or a transition when an arrow follows + /// the first name. Both begin with a state name, so the arrow is what tells + /// them apart and neither needs a keyword of its own. + fn parse_facet(&mut self) -> Result { + let start = self.expect_word("facet")?.span; + let name = self.take_identifier("a facet name")?; + if !self.check_word("on") { + return Err(syntax_span( + self.current_span(), + "a facet states the kind it classifies, written 'on '", + )); + } + self.next(); + let subject = self.parse_type()?; + self.open_block()?; + let mut states = Vec::new(); + let mut transitions = Vec::new(); + while !self.check(&TokenKind::Dedent) { + let doc = self.take_doc()?; + let first = self.take_identifier("a state name")?; + if self.consume(&TokenKind::RightArrow).is_some() { + if doc.is_some() { + return Err(syntax_span( + first.span, + "documentation describes a state; a transition is documented by the states it joins", + )); + } + let to = self.take_identifier("the state a transition reaches")?; + let end = self.expect_line_end()?; + transitions.push(FacetTransitionDecl { + span: first.span.join(end), + from: first, + to, + }); + continue; + } + // A state carrying nothing needs no block, the way a kind whose + // instances state nothing beyond their name needs none. + if !self.check(&TokenKind::Colon) { + let end = self.expect_line_end()?; + states.push(FacetStateDecl { + doc, + span: first.span.join(end), + name: first, + fields: Vec::new(), + }); + continue; + } + self.open_block()?; + let mut fields = Vec::new(); + while !self.check(&TokenKind::Dedent) { + fields.push(self.parse_field_line(false)?); + } + let end = self.expect(TokenKind::Dedent)?.span; + states.push(FacetStateDecl { + doc, + span: first.span.join(end), + name: first, + fields, + }); + } + let end = self.expect(TokenKind::Dedent)?.span; + Ok(FacetDecl { + doc: None, + name, + subject, + states, + transitions, + span: start.join(end), + }) + } + + /// `action centrifuge at for -> pellet:` + /// + /// The header is the phrase a workflow writes: words as they are, operands + /// in `<>`, results after `->`. The block types every operand and result, + /// and states the capability the verb needs. + fn parse_action(&mut self) -> Result { + let start = self.expect_word("action")?.span; + let name = self.take_identifier("an action name")?; + let mut phrase = vec![PhraseToken::Word(name.clone())]; + while !self.check(&TokenKind::RightArrow) && !self.check(&TokenKind::Colon) { + if self.consume(&TokenKind::Less).is_some() { + let operand = self.take_identifier("an operand name")?; + self.expect(TokenKind::Greater)?; + phrase.push(PhraseToken::Hole(operand)); + } else { + phrase.push(PhraseToken::Word(self.take_identifier("a phrase word")?)); + } + } + let mut results = Vec::new(); + if self.consume(&TokenKind::RightArrow).is_some() { + results.push(self.take_identifier("a result name")?); + while self.consume(&TokenKind::Comma).is_some() { + results.push(self.take_identifier("a result name")?); + } + } + self.open_block()?; + let mut bindings = Vec::new(); + let mut capability = None; + while !self.check(&TokenKind::Dedent) { + if self.check_word("requires") { + let keyword = self.next().expect("checked"); + if capability.is_some() { + return Err(syntax_span( + keyword.span, + "an action states the one capability it needs once", + )); + } + capability = Some(self.take_identifier("a capability")?); + self.expect_line_end()?; + continue; + } + let binding = self.take_identifier("an operand or result name")?; + self.expect(TokenKind::Colon)?; + // An ownership mode is a word before the type, and only an operand + // has one. A result is never owned by the action that yields it. + let mode = match () { + _ if self.check_word("take") => Some(OwnershipMode::Take), + _ if self.check_word("borrow") => Some(OwnershipMode::Borrow), + _ if self.check_word("copy") => Some(OwnershipMode::Copy), + _ => None, + }; + if mode.is_some() { + self.next(); + } + let ty = self.parse_type()?; + let end = self.expect_line_end()?; + bindings.push(ActionBinding { + span: binding.span.join(end), + name: binding, + mode, + ty, + }); + } + let end = self.expect(TokenKind::Dedent)?.span; + let capability = capability.ok_or_else(|| { + syntax_span( + start, + "an action states the capability a facility must offer to run it, written 'requires '", + ) + })?; + Ok(ActionDecl { + doc: None, + name, + phrase, + results, + bindings, + capability, + span: start.join(end), + }) + } + /// The `is Signal, Reporter` clause on a declaration that plays roles. fn parse_roles_clause(&mut self) -> Result, ParseError> { if !self.check_word("is") { @@ -930,7 +1094,13 @@ impl<'a> Parser<'a> { /// `100 ng/uL` and `Quantity`, so the two can never drift apart. fn parse_unit(&mut self) -> Result { let mut unit = self.take_identifier("a unit")?.value; - if self.consume(&TokenKind::Slash).is_some() { + // A denominator is a unit, so a slash followed by anything else is + // division. Without this, `20 uL / 2` reads the `2` as a denominator and + // a quantity cannot be divided at all. + if self.check(&TokenKind::Slash) + && matches!(self.peek_kind(1), Some(TokenKind::Identifier(_))) + { + self.next(); unit.push('/'); unit.push_str(&self.take_identifier("a unit denominator")?.value); } @@ -955,7 +1125,15 @@ impl<'a> Parser<'a> { && self.check(&TokenKind::Less) { self.next(); - let unit = self.parse_unit()?; + // `Quantity` asks for a measurement of something + // without pinning which unit it is written in, the way `any Signal` + // asks for a type playing a role without naming which. + let unit = if self.check_word("any") { + self.next(); + Unit::Dimension(self.take_identifier("a dimension")?.value) + } else { + Unit::Exact(self.parse_unit()?) + }; let end = self.expect(TokenKind::Greater)?.span; return Ok(TypeExpr::Quantity { unit, @@ -1002,7 +1180,20 @@ impl<'a> Parser<'a> { let span = name.span.join(role.span); return Ok(TypeArgument::Binding { name, role, span }); } - Ok(TypeArgument::Type(self.parse_type()?)) + let subject = self.parse_type()?; + // `is` after an argument narrows it to one facet state. It reads as the + // same word that says a type plays a role, because it says the same + // kind of thing: this material is one of the things its kind may be. + if self.check_word("is") { + self.next(); + let state = self.take_identifier("a facet state")?; + return Ok(TypeArgument::InState { + span: subject.span().join(state.span), + subject: Box::new(subject), + state, + }); + } + Ok(TypeArgument::Type(subject)) } fn parse_expr(&mut self) -> Result { @@ -1117,6 +1308,20 @@ impl<'a> Parser<'a> { field, span, }; + } else if self.check_word("in") + && matches!(self.peek_kind(1), Some(TokenKind::Identifier(_))) + { + // `500 mL in uL` converts. A loop header never reaches here, + // because `for` takes its binding as a name rather than as an + // expression and reads its own `in`. + self.next(); + let unit = self.take_identifier("a unit to convert to")?; + let span = expression.span().join(unit.span); + expression = Expr::Convert { + value: Box::new(expression), + unit, + span, + }; } else if is_numeric(&expression) && self.peek_identifier().is_some() { let unit_start = self.current_span(); let unit = self.parse_unit()?; diff --git a/crates/lab-language/src/provenance.rs b/crates/lab-language/src/provenance.rs index b6106c65..69e58363 100644 --- a/crates/lab-language/src/provenance.rs +++ b/crates/lab-language/src/provenance.rs @@ -101,7 +101,14 @@ impl LineageMap { /// Positional rather than keyed by name, because a binding renames a result: /// `evidence <- quantify sample` and `first <- quantify sample` bind the same /// contract result to different names. -type LineageTable = BTreeMap>; +type LineageTable = BTreeMap; + +/// What one action does to the lineages passing through it. +pub(crate) struct ActionLineage { + results: Vec, + /// Operands whose lineage no result carries on. + inert: Vec, +} /// What every workflow in a module knows about where its materials came from, /// keyed by workflow name. @@ -128,7 +135,13 @@ pub(crate) fn lineage_table(library: &StandardLibrary) -> LineageTable { .action_specs() .map(|action| { let results = action.results.iter().map(|result| result.lineage).collect(); - (action.operation.to_owned(), results) + ( + action.operation.to_owned(), + ActionLineage { + results, + inert: action.inert.clone(), + }, + ) }) .collect() } @@ -139,6 +152,7 @@ pub(crate) fn analyze(body: &[CheckedStatement], table: &LineageTable) -> Lineag table, next: 0, map: LineageMap::default(), + shelf: BTreeMap::new(), }; analyzer.block(body); analyzer.map @@ -148,6 +162,15 @@ struct Analyzer<'a> { table: &'a LineageTable, next: usize, map: LineageMap, + /// The origin already minted for each thing fetched off a shelf. + /// + /// Naming the same catalogued item twice fetches the same thing, so two + /// such materials are one entity. Minting an origin per fetch would let a + /// program claim two biological replicates by writing `provision` twice, + /// which is the pseudo-replication this analysis exists to refuse. Whether + /// a facility holds one lot or two is its own question, and one a program + /// cannot see. + shelf: BTreeMap, Origin>, } impl Analyzer<'_> { @@ -209,6 +232,11 @@ impl Analyzer<'_> { if !mentions_material(&argument.value.r#type) { continue; } + // What an organism sits on is not part of the organism. + if declared.is_some_and(|action| action.inert.iter().any(|name| name == &argument.name)) + { + continue; + } match self.map.of(&argument.value) { Provenance::From(origins) => inherited.extend(origins), // A family's size is a runtime value, so what continues from @@ -225,7 +253,7 @@ impl Analyzer<'_> { let mut event = None; for (position, result) in results.iter().enumerate() { let lineage = declared - .and_then(|lineages| lineages.get(position)) + .and_then(|action| action.results.get(position)) .copied() .unwrap_or_default(); let provenance = match lineage { @@ -246,13 +274,28 @@ impl Analyzer<'_> { Lineage::Continues if any_unknown => Provenance::Unknown, // A result that continues nothing must start something: no // material flowed in, so two of these are as independent as two - // separate assemblies or two separate batches of cells. + // separate assemblies. Fetching a named thing off a shelf is + // the exception, because naming it twice fetches one thing. Lineage::Continues if inherited.is_empty() => { - let origin = *event.get_or_insert_with(|| { - let origin = Origin(self.next); - self.next += 1; - origin - }); + let origin = match fetched(action) { + Some(key) => match self.shelf.get(&key) { + Some(origin) => *origin, + None => { + let origin = *event.get_or_insert_with(|| { + let origin = Origin(self.next); + self.next += 1; + origin + }); + self.shelf.insert(key, origin); + origin + } + }, + None => *event.get_or_insert_with(|| { + let origin = Origin(self.next); + self.next += 1; + origin + }), + }; Provenance::From(BTreeSet::from([origin])) } Lineage::Continues => Provenance::From(inherited.clone()), @@ -262,6 +305,23 @@ impl Analyzer<'_> { } } +/// What this action names, when it establishes material by naming a thing +/// rather than by working on one. +/// +/// The operation and every name it refers to identify the thing fetched, so two +/// fetches of one item share a key and two fetches of different items do not. +/// An action that refers to nothing has nothing to be the same as. +fn fetched(action: &ResolvedAction) -> Option> { + let mut key = vec![action.operation.clone()]; + for argument in &action.arguments { + let CheckedExpression::Reference { path, .. } = &argument.value.value else { + return None; + }; + key.extend(path.iter().cloned()); + } + (key.len() > 1).then_some(key) +} + fn is_collection(r#type: &CheckedType) -> bool { matches!(r#type, CheckedType::List { .. }) } @@ -314,8 +374,13 @@ mod tests { const SETUP: &str = r#"use std.lab.plasmid use std.bio.designs -buy chassis DH5alpha +buy chassis DH5alpha: + competence = competent + efficiency = 1e9 cfu/ug buy antibiotic chloramphenicol +buy medium LB_agar: + pouring = poured + selection = chloramphenicol strain host: chassis = DH5alpha @@ -326,6 +391,53 @@ plasmid p_reporter: "#; + /// Naming one catalogued item twice fetches one thing, so two handles onto + /// it are one entity. Two provisions counted as two independent samples + /// would let a program claim replicates it does not have. + #[test] + fn fetching_one_item_twice_is_one_entity() { + let map = lineage_of( + &format!( + "{SETUP}{}", + r#"workflow fetch() -> (a: Material, b: Material): + a <- provision DH5alpha + b <- provision DH5alpha + return a, b +"# + ), + "fetch", + ); + let a = map.get("a").expect("a is bound"); + let b = map.get("b").expect("b is bound"); + assert!( + same_entity(a, b), + "one shelf item fetched twice is one thing: {a:?} vs {b:?}" + ); + assert_eq!(a.independent_count(), Some(1)); + } + + /// Different items are different things, whatever they are fetched for. + #[test] + fn fetching_two_items_gives_two_entities() { + let map = lineage_of( + &format!( + "{SETUP}{}", + r#"workflow fetch() -> (cells: Material, drug: Material): + cells <- provision DH5alpha + drug <- provision chloramphenicol + return cells, drug +"# + ), + "fetch", + ); + let cells = map.get("cells").expect("cells is bound"); + let drug = map.get("drug").expect("drug is bound"); + assert!( + !same_entity(cells, drug), + "a chassis and an antibiotic are not one thing: {cells:?} vs {drug:?}" + ); + } + /// Recovering and plating do not make a second organism, so everything /// downstream of one transformation is the same entity. #[test] @@ -333,12 +445,14 @@ plasmid p_reporter: let map = lineage_of( &format!( "{SETUP}{}", - r#"workflow build(carried: Material) -> (strain: Material, plate: Material): + r#"workflow build(carried: Material) -> (strain: Material, plate: Material): dependencies = [carried] cells <- provision DH5alpha strain, culture <- transform host from dependencies into cells culture <- recover culture for 1 h - plate <- plate culture on chloramphenicol + agar <- provision LB_agar + + plate <- plate culture on agar return strain, plate "# ), @@ -361,11 +475,14 @@ plasmid p_reporter: let map = lineage_of( &format!( "{SETUP}{}", - r#"workflow build(carried: Material) -> (strain: Material, plate: Material): + r#"workflow build(carried: Material) -> (strain: Material, plate: Material): dependencies = [carried] cells <- provision DH5alpha strain, culture <- transform host from dependencies into cells - plate <- plate culture on chloramphenicol + culture <- recover culture for 1 h + agar <- provision LB_agar + + plate <- plate culture on agar return strain, plate "# ), @@ -388,11 +505,14 @@ plasmid p_reporter: let map = lineage_of( &format!( "{SETUP}{}", - r#"workflow build(carried: Material) -> (strain: Material, plate: Material): + r#"workflow build(carried: Material) -> (strain: Material, plate: Material): dependencies = [carried] cells <- provision DH5alpha strain, culture <- transform host from dependencies into cells - plate <- plate culture on chloramphenicol + culture <- recover culture for 1 h + agar <- provision LB_agar + + plate <- plate culture on agar candidates <- pick 4 isolated colonies from plate screening <- screen candidates against p_reporter <- dispose screening.clones.highest_confidence diff --git a/crates/lab-language/src/render.rs b/crates/lab-language/src/render.rs index eefdda47..151bdbf6 100644 --- a/crates/lab-language/src/render.rs +++ b/crates/lab-language/src/render.rs @@ -32,6 +32,25 @@ pub fn render_checked_module(module: &CheckedModule) -> String { acceptance.len() )), CheckedDeclaration::Role { name, .. } => output.push_str(&format!(" - role {name}\n")), + CheckedDeclaration::Action { + name, + operands, + results, + .. + } => output.push_str(&format!( + " - action {name} ({} operands, {} results)\n", + operands.len(), + results.len() + )), + CheckedDeclaration::Facet { + name, + subject, + states, + .. + } => output.push_str(&format!( + " - facet {name} on {subject} ({} states)\n", + states.len() + )), CheckedDeclaration::ArtifactKind { name, produces, .. } => { output.push_str(&format!(" - artifact {name} -> {produces}\n")) } diff --git a/crates/lab-language/src/semantics/interface.rs b/crates/lab-language/src/semantics/interface.rs index 2a84b4fb..c35317db 100644 --- a/crates/lab-language/src/semantics/interface.rs +++ b/crates/lab-language/src/semantics/interface.rs @@ -10,6 +10,7 @@ use crate::checked::{CheckedField, CheckedSchemaField, CheckedType}; pub enum ExportKind { Type, Role, + Facet, ArtifactKind, Value, Function, @@ -43,6 +44,15 @@ pub struct ModuleExport { /// against. A word means nothing to an importer without it. #[serde(default, skip_serializing_if = "Option::is_none")] pub schema: Option, + /// For a facet export, the states it admits and the changes between them. + /// An importer that cannot see the states cannot constrain a material to + /// one, so the states are as much of the surface as a schema is. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub facet: Option, + /// For an action export, the phrase, operands, results, and capability an + /// importer checks a workflow against and a compiler derives a method from. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub action: Option, /// Type parameters and their bounds, for a type or a callable alike. /// /// Without these an importer cannot tell a parameter apart from a nominal @@ -62,6 +72,27 @@ pub struct ArtifactSchema { pub declares: Option, } +/// What a package's action verb means to a module that imports it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ActionSurface { + pub operation: String, + pub phrase: Vec, + pub operands: Vec, + pub results: Vec, + pub capability: String, +} + +/// What a package's facet means to a module that imports it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FacetSurface { + pub subject: CheckedType, + /// The states in declaration order, so the first stays identifiable as the + /// state a newly established material is in. + pub states: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub transitions: Vec, +} + /// The type parameters a declaration takes, in declaration order, with whatever /// each is bounded by. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/lab-language/src/semantics/mod.rs b/crates/lab-language/src/semantics/mod.rs index 2f106e68..37ec25ca 100644 --- a/crates/lab-language/src/semantics/mod.rs +++ b/crates/lab-language/src/semantics/mod.rs @@ -11,6 +11,6 @@ mod interface; pub use grounding::Grounding; pub use ids::{DefinitionId, ModuleId}; pub use interface::{ - ArtifactSchema, CallableSignature, ExportKind, ModuleExport, ModuleInterface, - SemanticEnvironment, TypeParameters, + ActionSurface, ArtifactSchema, CallableSignature, ExportKind, FacetSurface, ModuleExport, + ModuleInterface, SemanticEnvironment, TypeParameters, }; diff --git a/crates/lab-language/src/standard_library/authored/competence.lab b/crates/lab-language/src/standard_library/authored/competence.lab new file mode 100644 index 00000000..d3626727 --- /dev/null +++ b/crates/lab-language/src/standard_library/authored/competence.lab @@ -0,0 +1,92 @@ +/*! + * Making a chassis competent. + * + * Competent cells are grown up, chilled, spun into a pellet, and washed into a + * cold buffer until they will take up DNA. None of these steps is assembly or + * transformation, so none had a verb until a package could declare one. Each + * verb here is an `action`: it names the material it takes, the material it + * yields and the state that yielding leaves it in, and the capability a bench + * or a robot must offer to run it. The compiler derives a manual method for + * each, so the sequence plans without a line of Rust. + * + * A buffer and a medium are both solutions a laboratory pours, and both are + * washed or grown into cells, so they share the `Solution` role rather than + * repeating what a solution can do on each kind. + */ + +use std.bio.designs + +/** + * A salt solution cells are washed and resuspended in. + * + * A buffer and a medium are both solutions a laboratory pours, so both play the + * `Solution` role and a verb that resuspends cells asks for either. The + * concentration is the batch's own: a competent-cell protocol is written for a + * molarity of calcium chloride, and what to weigh out is that times the volume. + */ +artifact Buffer is Solution: + concentration?: Quantity + +/** + * How far a batch of cells is along the way to being competent. + * + * Competence itself is a separate fact, declared where a chassis is: cells are + * competent or they are not, and transformation is the one operation that + * cares. These are the physical states a preparation passes through before it + * gets there, so a verb that spins cells down can say it takes ones that are + * growing and leaves ones that are pelleted. + */ +facet Growth on Chassis: + /** Cells sitting in the fridge or freezer, not yet grown. */ + dormant + /** Cells multiplying in warm medium. */ + growing + /** Cells spun into a pellet, the spent medium poured off. */ + pelleted + + dormant -> growing + growing -> pelleted + pelleted -> growing + +/** + * Grow cells up to a target optical density. + * + * The target is read at 600 or 700 nanometres, and the two do not convert: an + * OD600 of 0.4 is not an OD700 of 0.4, so the unit says which meter the number + * came off. Either reaches the same growing culture, so the operand admits + * either and the protocol writes whichever its plate reader reports. + */ +action grow at to -> culture: + cells: take Material + temperature: Quantity + target: Quantity | Quantity + culture: Material + requires ShakingIncubation + +/** Chill a growing culture on ice before it is spun down. */ +action chill for -> chilled: + cells: take Material + duration: Quantity + chilled: Material + requires Refrigeration + +/** Spin a chilled culture into a pellet at a stated relative force. */ +action centrifuge at for -> pellet: + cells: take Material + force: Quantity + duration: Quantity + pellet: Material + requires Centrifugation + +/** + * Resuspend a pellet in cold buffer, which is the wash that makes it competent. + * + * The buffer is a solution, so the same verb pours a calcium-chloride wash or + * any other a protocol calls for. What comes out is competent: ready for the + * one operation that takes cells that are. + */ +action resuspend in -> competent: + cells: take Material + buffer: take Material + competent: Material + requires ManualPipetting diff --git a/crates/lab-language/src/standard_library/authored/designs.lab b/crates/lab-language/src/standard_library/authored/designs.lab index dc39a20b..0f4d4206 100644 --- a/crates/lab-language/src/standard_library/authored/designs.lab +++ b/crates/lab-language/src/standard_library/authored/designs.lab @@ -86,9 +86,95 @@ artifact Chassis is FunctionalEntity: recovery_temperature?: Quantity recovery_duration?: Quantity +/** + * Where an organism is in the course of being grown. + * + * A culture was a type of its own, which is why it could never say what was + * growing in it. It is an organism in a state, so the organism is the type and + * how far along it is travels beside it. + */ +facet Cultivation on Strain: + /** A design nothing has been grown from yet. */ + designed + /** Cells that have taken up DNA and are recovering. */ + transformed + /** A recovered culture, ready to be plated or grown on. */ + recovered + /** Thinned so single colonies can be told apart. */ + diluted + /** One colony picked from a plate, which is one transformant. */ + isolated + /** Grown from a single colony. */ + grown + + designed -> transformed + transformed -> recovered + recovered -> diluted + recovered -> grown + diluted -> isolated + isolated -> grown + +/** + * Whether a medium has been poured and what has been put on it. + * + * A plate was a type of its own and could not say what it was poured from, so + * plating on the wrong medium was not something the compiler could see. + */ +facet Pouring on Medium: + /** Made up, and still in the bottle. */ + prepared + /** Poured into plates and set. */ + poured + /** Poured, and spread with something. */ + inoculated + + prepared -> poured + poured -> inoculated + +/** + * Whether a chassis will take up DNA. + * + * Cells are made competent or bought that way, and the difference matters to + * the one operation that needs it: transformation takes cells that are, and + * says so, rather than trusting that whatever was fetched will do. + * + * How competent they are is the batch's own number. A preparation is accepted + * on a control transformation, so the efficiency belongs to the cells rather + * than to the strain they came from or the plasmid they will carry. + */ +facet Competence on Chassis: + /** Cells as they grow, which take up nothing. */ + naive + /** Cells a transformation may be attempted in. */ + competent: + efficiency: Quantity + + naive -> competent + /** A selection agent a transformed culture is plated on. */ artifact Antibiotic is SimpleChemical +/** + * What an organism is grown in or on. + * + * A medium is a recipe: what goes in it, and how much of each per unit volume. + * Concentrations rather than masses, because a recipe is the same whether a + * laboratory makes half a litre or five, and what to weigh out is the recipe + * times the batch. + * + * A solid medium is a liquid one with a gelling agent, which is why agar is a + * component rather than a second kind. + */ +artifact Medium is FunctionalEntity: + components?: List + ph?: Decimal + selection?: Antibiotic + +/** How much of one substance a medium holds per unit volume. */ +record Ingredient: + substance: String + concentration: Quantity + /** * A DNA design a laboratory can build. * diff --git a/crates/lab-language/src/standard_library/bio/build.rs b/crates/lab-language/src/standard_library/bio/build.rs index 20f22b1b..61bdee44 100644 --- a/crates/lab-language/src/standard_library/bio/build.rs +++ b/crates/lab-language/src/standard_library/bio/build.rs @@ -12,30 +12,31 @@ pub(in crate::standard_library::bio) fn module() -> StandardModule { let material = Ty::material; let concrete = ContractType::Concrete; let action = ActionContractSpec { - operation: "std.bio.build.realize", + operation: "std.bio.build.realize".to_owned(), phrase: vec![ - PhrasePart::Word("realize"), - PhrasePart::Operand { - name: "design", - r#type: concrete(named("Plasmid")), - mode: OwnershipMode::Copy, - }, + PhrasePart::word("realize"), + // Realizing is making the thing a declaration describes, whatever + // kind of thing that is. The declaration carries the recipe, so a + // plasmid realizes by assembly and a medium by weighing out, and + // which is a Method's business rather than this contract's. + PhrasePart::operand("design", ContractType::AnyValue, OwnershipMode::Copy), // A realization with no artifact inputs writes nothing, so leaving // the clause out says the same thing as passing an empty list. PhrasePart::Optional(vec![ - PhrasePart::Word("from"), - PhrasePart::Operand { - name: "dependencies", - r#type: concrete(Ty::List(Box::new(material(named("Plasmid"))))), - mode: OwnershipMode::Take, - }, + PhrasePart::word("from"), + PhrasePart::operand( + "dependencies", + concrete(Ty::List(Box::new(material(named("Plasmid"))))), + OwnershipMode::Take, + ), ]), ], // Realizing a design assembles DNA rather than establishing an // organism, so the product carries the lineage of what went into it. + inert: Vec::new(), results: vec![ResultSpec { - name: "product", - r#type: concrete(material(named("Plasmid"))), + name: "product".to_owned(), + r#type: ContractType::MaterialOf("design".to_owned()), lineage: Lineage::Continues, }], }; diff --git a/crates/lab-language/src/standard_library/catalog.rs b/crates/lab-language/src/standard_library/catalog.rs index f7eb38d3..324b42a3 100644 --- a/crates/lab-language/src/standard_library/catalog.rs +++ b/crates/lab-language/src/standard_library/catalog.rs @@ -318,6 +318,10 @@ const AUTHORED_SOURCES: &[(&str, &str)] = &[ "std.bio.golden_gate", include_str!("authored/golden_gate.lab"), ), + ( + "std.lab.competence", + include_str!("authored/competence.lab"), + ), ]; static AUTHORED: OnceLock>> = OnceLock::new(); @@ -407,7 +411,7 @@ impl StandardLibrary { modules: impl IntoIterator, ) -> Result { let mut indexed = BTreeMap::new(); - let mut operations = BTreeMap::<&str, &str>::new(); + let mut operations = BTreeMap::::new(); for module in modules { module.validate()?; if indexed.contains_key(module.path) { @@ -423,11 +427,17 @@ impl StandardLibrary { .iter() .map(|constructor| constructor.operation), ) - .chain(module.actions.iter().map(|action| action.operation)) + .chain( + module + .actions + .iter() + .map(|action| action.operation.as_str()), + ) + .map(str::to_owned) { - if let Some(previous) = operations.insert(operation, module.path) { + if let Some(previous) = operations.insert(operation.clone(), module.path) { return Err(CatalogError::DuplicateOperation { - operation: operation.to_owned(), + operation, first_module: previous.to_owned(), second_module: module.path.to_owned(), }); @@ -709,12 +719,13 @@ mod tests { #[test] fn rejects_malformed_action_contracts_during_registration() { let malformed = ActionContractSpec { - operation: "std.test.broken", - phrase: vec![PhrasePart::Operand { - name: "input", - r#type: ContractType::Concrete(Ty::String), - mode: crate::OwnershipMode::Copy, - }], + operation: "std.test.broken".to_owned(), + phrase: vec![PhrasePart::operand( + "input", + ContractType::Concrete(Ty::String), + crate::OwnershipMode::Copy, + )], + inert: Vec::new(), results: Vec::new(), }; let result = StandardLibrary::from_modules([ diff --git a/crates/lab-language/src/standard_library/contract.rs b/crates/lab-language/src/standard_library/contract.rs index 8cb1d924..3e9c10ce 100644 --- a/crates/lab-language/src/standard_library/contract.rs +++ b/crates/lab-language/src/standard_library/contract.rs @@ -8,32 +8,32 @@ use crate::type_system::Ty; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum ContractType { Concrete(Ty), - SameAs(&'static str), + SameAs(String), AnyMaterial, /// Any declared thing, whatever its type. Fetching one off the shelf does /// not depend on what it is. AnyValue, /// Material of whatever an earlier operand was. What comes back from the /// shelf is the thing that was asked for. - MaterialOf(&'static str), + MaterialOf(String), } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum PhrasePart { - Word(&'static str), + Word(String), Operand { - name: &'static str, + name: String, r#type: ContractType, mode: OwnershipMode, }, Integer { - name: &'static str, + name: String, signed: bool, }, Quantity { - name: &'static str, + name: String, signed: bool, - units: &'static [&'static str], + units: Vec, }, /// A clause a phrase may leave out. Omitting it binds every operand it /// carries to the empty collection, so an optional clause may only carry @@ -45,6 +45,41 @@ pub(crate) enum PhrasePart { } impl PhrasePart { + /// A literal word in a phrase, such as `capture` or `from`. + pub(crate) fn word(word: impl Into) -> Self { + Self::Word(word.into()) + } + + /// A material or value operand. + pub(crate) fn operand( + name: impl Into, + r#type: ContractType, + mode: OwnershipMode, + ) -> Self { + Self::Operand { + name: name.into(), + r#type, + mode, + } + } + + /// A whole-number operand. + pub(crate) fn integer(name: impl Into, signed: bool) -> Self { + Self::Integer { + name: name.into(), + signed, + } + } + + /// A measurement operand, in one of the stated units. + pub(crate) fn quantity(name: impl Into, signed: bool, units: &[&str]) -> Self { + Self::Quantity { + name: name.into(), + signed, + units: units.iter().map(|unit| (*unit).to_owned()).collect(), + } + } + /// The words and operands a phrase part contributes, flattening an optional /// clause into the parts it would contribute when present. pub(crate) fn parts(&self) -> &[PhrasePart] { @@ -75,27 +110,34 @@ pub(crate) enum Lineage { #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ResultSpec { - pub name: &'static str, + pub name: String, pub r#type: ContractType, pub lineage: Lineage, } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ActionContractSpec { - pub operation: &'static str, + pub operation: String, pub phrase: Vec, pub results: Vec, + /// Operands whose lineage a result does not carry on. + /// + /// Lineage answers which samples are the same organism, so only what an + /// organism is made of contributes to it. A plate is a culture spread on + /// agar: the culture is the organism and the agar is what it sits on, and + /// counting the agar would make one plate look like two independent things. + pub inert: Vec, } impl ActionContractSpec { - pub(crate) fn source_name(&self) -> Option<&'static str> { + pub(crate) fn source_name(&self) -> Option<&str> { match self.phrase.first() { Some(PhrasePart::Word(name)) => Some(name), _ => None, } } - pub(in crate::standard_library) fn validate(&self) -> Result<(), String> { + pub(crate) fn validate(&self) -> Result<(), String> { let action = self .source_name() .ok_or_else(|| "action phrase must begin with its source name".to_owned())?; @@ -117,17 +159,17 @@ impl ActionContractSpec { } PhrasePart::Operand { name, r#type, .. } => { if let ContractType::SameAs(reference) = r#type - && !operands.contains(reference) + && !operands.contains(reference.as_str()) { return Err(format!( "action argument '{name}' references unknown earlier operand '{reference}'" )); } - operands.insert(*name); - (*name, None) + operands.insert(name.as_str()); + (name.as_str(), None) } - PhrasePart::Integer { name, .. } => (*name, None), - PhrasePart::Quantity { name, units, .. } => (*name, Some(*units)), + PhrasePart::Integer { name, .. } => (name.as_str(), None), + PhrasePart::Quantity { name, units, .. } => (name.as_str(), Some(units.as_slice())), PhrasePart::Optional(_) => { return Err("an optional clause cannot nest another".to_owned()); } @@ -170,7 +212,7 @@ impl ActionContractSpec { let mut result_names = BTreeSet::new(); for result in &self.results { - if !result_names.insert(result.name) { + if !result_names.insert(result.name.as_str()) { return Err(format!( "action result '{}' is declared more than once", result.name @@ -179,7 +221,7 @@ impl ActionContractSpec { match &result.r#type { ContractType::Concrete(_) => {} ContractType::MaterialOf(reference) | ContractType::SameAs(reference) - if operands.contains(reference) => {} + if operands.contains(reference.as_str()) => {} ContractType::MaterialOf(reference) | ContractType::SameAs(reference) => { return Err(format!( "action result '{}' references unknown operand '{reference}'", @@ -204,27 +246,24 @@ mod tests { fn contract(phrase: Vec) -> ActionContractSpec { ActionContractSpec { - operation: "test.action", + operation: "test.action".to_owned(), phrase, + inert: Vec::new(), results: Vec::new(), } } fn optional_operand(r#type: ContractType) -> PhrasePart { PhrasePart::Optional(vec![ - PhrasePart::Word("from"), - PhrasePart::Operand { - name: "items", - r#type, - mode: OwnershipMode::Take, - }, + PhrasePart::word("from"), + PhrasePart::operand("items", r#type, OwnershipMode::Take), ]) } #[test] fn an_optional_clause_may_only_carry_collections() { let listed = contract(vec![ - PhrasePart::Word("act"), + PhrasePart::word("act"), optional_operand(ContractType::Concrete(Ty::List(Box::new(Ty::named( "Plasmid", ))))), @@ -232,7 +271,7 @@ mod tests { listed.validate().unwrap(); let scalar = contract(vec![ - PhrasePart::Word("act"), + PhrasePart::word("act"), optional_operand(ContractType::Concrete(Ty::named("Plasmid"))), ]); let error = scalar @@ -244,12 +283,12 @@ mod tests { #[test] fn an_optional_clause_must_announce_itself_with_a_word() { let error = contract(vec![ - PhrasePart::Word("act"), - PhrasePart::Optional(vec![PhrasePart::Operand { - name: "items", - r#type: ContractType::Concrete(Ty::List(Box::new(Ty::named("Plasmid")))), - mode: OwnershipMode::Take, - }]), + PhrasePart::word("act"), + PhrasePart::Optional(vec![PhrasePart::operand( + "items", + ContractType::Concrete(Ty::List(Box::new(Ty::named("Plasmid")))), + OwnershipMode::Take, + )]), ]) .validate() .expect_err("without a leading word an omitted clause is indistinguishable"); @@ -259,12 +298,12 @@ mod tests { #[test] fn an_optional_operand_still_shares_the_one_argument_namespace() { let error = contract(vec![ - PhrasePart::Word("act"), - PhrasePart::Operand { - name: "items", - r#type: ContractType::Concrete(Ty::named("Plasmid")), - mode: OwnershipMode::Copy, - }, + PhrasePart::word("act"), + PhrasePart::operand( + "items", + ContractType::Concrete(Ty::named("Plasmid")), + OwnershipMode::Copy, + ), optional_operand(ContractType::Concrete(Ty::List(Box::new(Ty::named( "Plasmid", ))))), diff --git a/crates/lab-language/src/standard_library/lab/plasmid.rs b/crates/lab-language/src/standard_library/lab/plasmid.rs index 657ba094..ed868b6e 100644 --- a/crates/lab-language/src/standard_library/lab/plasmid.rs +++ b/crates/lab-language/src/standard_library/lab/plasmid.rs @@ -11,227 +11,258 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { let copy = OwnershipMode::Copy; let borrow = OwnershipMode::Borrow; let take = OwnershipMode::Take; - let operand = |name, r#type, mode| PhrasePart::Operand { name, r#type, mode }; + let operand = |name: &str, r#type, mode| PhrasePart::operand(name, r#type, mode); // Most results are the same material further along. The two that are not // say so: a transformation establishes an organism, and each picked colony // is an independent transformant. - let result = |name, r#type| ResultSpec { - name, + let result = |name: &str, r#type| ResultSpec { + name: name.to_owned(), r#type, lineage: Lineage::Continues, }; - let begins = |name, r#type| ResultSpec { - name, + let begins = |name: &str, r#type| ResultSpec { + name: name.to_owned(), r#type, lineage: Lineage::Begins, }; let concrete = ContractType::Concrete; let named = Ty::named; let material = Ty::material; + // A culture and a picked colony are one organism at different points in + // being grown, and a plate is a medium that has been poured. Each was a + // fieldless type of its own, which is why none could name what it was made + // of. Naming the state instead keeps the design underneath readable. + let in_state = |subject: Ty, state: &str| Ty::InState(Box::new(subject), state.to_owned()); + let strain = |state: &str| material(in_state(named("Strain"), state)); + let plate = |state: &str| material(in_state(named("Medium"), state)); let actions = vec![ ActionContractSpec { - operation: "std.lab.plasmid.capture", + operation: "std.lab.plasmid.capture".to_owned(), phrase: vec![ - PhrasePart::Word("capture"), - PhrasePart::Word("image"), - PhrasePart::Word("of"), - operand("plate", concrete(material(named("Plate"))), borrow), + PhrasePart::word("capture"), + PhrasePart::word("image"), + PhrasePart::word("of"), + operand("plate", concrete(plate("inoculated")), borrow), ], + inert: Vec::new(), results: vec![result("image", concrete(named("Image")))], }, ActionContractSpec { - operation: "std.lab.plasmid.synthesize", + operation: "std.lab.plasmid.synthesize".to_owned(), phrase: vec![ - PhrasePart::Word("synthesize"), + PhrasePart::word("synthesize"), operand("design", concrete(named("Plasmid")), copy), ], + inert: Vec::new(), results: vec![result( "fragments", concrete(Ty::List(Box::new(named("Fragment")))), )], }, ActionContractSpec { - operation: "std.lab.plasmid.assemble", + operation: "std.lab.plasmid.assemble".to_owned(), phrase: vec![ - PhrasePart::Word("assemble"), + PhrasePart::word("assemble"), operand( "fragments", concrete(Ty::List(Box::new(named("Fragment")))), take, ), ], + inert: Vec::new(), results: vec![result("construct", concrete(material(named("Plasmid"))))], }, ActionContractSpec { - operation: "std.lab.plasmid.provision", + operation: "std.lab.plasmid.provision".to_owned(), phrase: vec![ - PhrasePart::Word("provision"), + PhrasePart::word("provision"), operand("item", ContractType::AnyValue, copy), ], // Whether this laboratory bought the thing or made it last month is // not provision's business: it says what to fetch, and whether one // is available is a question for the plan. - results: vec![result("material", ContractType::MaterialOf("item"))], + inert: Vec::new(), + results: vec![result( + "material", + ContractType::MaterialOf("item".to_owned()), + )], }, ActionContractSpec { - operation: "std.lab.plasmid.transform", + operation: "std.lab.plasmid.transform".to_owned(), phrase: vec![ - PhrasePart::Word("transform"), + PhrasePart::word("transform"), operand("design", concrete(named("Strain")), copy), - PhrasePart::Word("from"), + PhrasePart::word("from"), operand( "plasmids", concrete(Ty::List(Box::new(material(named("Plasmid"))))), take, ), - PhrasePart::Word("into"), - operand("cells", concrete(material(named("Chassis"))), take), + PhrasePart::word("into"), + // Cells that were never made competent take up nothing, so the + // state is required rather than assumed. A chassis fetched off + // the shelf carries it because its declaration states it. + operand( + "cells", + concrete(material(Ty::InState( + Box::new(named("Chassis")), + "competent".to_owned(), + ))), + take, + ), ], + inert: Vec::new(), results: vec![ begins("strain", concrete(material(named("Strain")))), - begins("culture", concrete(material(named("Culture")))), + begins("culture", concrete(strain("transformed"))), ], }, ActionContractSpec { - operation: "std.lab.plasmid.recover", + operation: "std.lab.plasmid.recover".to_owned(), phrase: vec![ - PhrasePart::Word("recover"), - operand("culture", concrete(material(named("Culture"))), take), - PhrasePart::Word("for"), - PhrasePart::Quantity { - name: "duration", - signed: false, - units: &["min", "h"], - }, + PhrasePart::word("recover"), + operand("culture", concrete(strain("transformed")), take), + PhrasePart::word("for"), + PhrasePart::quantity("duration", false, &["min", "h"]), ], - results: vec![result("culture", concrete(material(named("Culture"))))], + inert: Vec::new(), + results: vec![result("culture", concrete(strain("recovered")))], }, ActionContractSpec { - operation: "std.lab.plasmid.dilute", + operation: "std.lab.plasmid.dilute".to_owned(), phrase: vec![ - PhrasePart::Word("dilute"), - operand("culture", concrete(material(named("Culture"))), take), + PhrasePart::word("dilute"), + operand("culture", concrete(strain("recovered")), take), ], - results: vec![result("culture", concrete(material(named("Culture"))))], + inert: Vec::new(), + results: vec![result("culture", concrete(strain("diluted")))], }, ActionContractSpec { - operation: "std.lab.plasmid.plate", + operation: "std.lab.plasmid.plate".to_owned(), phrase: vec![ - PhrasePart::Word("plate"), - operand("culture", concrete(material(named("Culture"))), take), - PhrasePart::Word("on"), - operand("antibiotic", concrete(named("Antibiotic")), copy), + PhrasePart::word("plate"), + // A culture is plated whether or not it was thinned first. + // Diluting matters for counting what grows, not for the act of + // spreading it, so both states are spreadable. + operand( + "culture", + concrete(Ty::Union(vec![strain("recovered"), strain("diluted")])), + take, + ), + PhrasePart::word("on"), + // What a culture is spread on is a medium that has been poured, + // so plating on the wrong one is now something to see rather + // than a name nobody checked. + operand("medium", concrete(plate("poured")), take), ], - results: vec![result("plate", concrete(material(named("Plate"))))], + inert: vec!["medium".to_owned()], + results: vec![result("plate", concrete(plate("inoculated")))], }, ActionContractSpec { - operation: "std.lab.plasmid.pick", + operation: "std.lab.plasmid.pick".to_owned(), phrase: vec![ - PhrasePart::Word("pick"), - PhrasePart::Integer { - name: "count", - signed: false, - }, - PhrasePart::Word("isolated"), - PhrasePart::Word("colonies"), - PhrasePart::Word("from"), - operand("plate", concrete(material(named("Plate"))), borrow), + PhrasePart::word("pick"), + PhrasePart::integer("count", false), + PhrasePart::word("isolated"), + PhrasePart::word("colonies"), + PhrasePart::word("from"), + operand("plate", concrete(plate("inoculated")), borrow), ], + inert: Vec::new(), results: vec![begins( "candidates", - concrete(Ty::List(Box::new(material(named("Clone"))))), + concrete(Ty::List(Box::new(strain("isolated")))), )], }, ActionContractSpec { - operation: "std.lab.plasmid.screen", + operation: "std.lab.plasmid.screen".to_owned(), phrase: vec![ - PhrasePart::Word("screen"), + PhrasePart::word("screen"), operand( "candidates", - concrete(Ty::List(Box::new(material(named("Clone"))))), + concrete(Ty::List(Box::new(strain("isolated")))), take, ), - PhrasePart::Word("against"), + PhrasePart::word("against"), operand("design", concrete(named("Plasmid")), copy), ], + inert: Vec::new(), results: vec![result("screening", concrete(named("Screening")))], }, ActionContractSpec { - operation: "std.lab.plasmid.grow", + operation: "std.lab.plasmid.culture".to_owned(), phrase: vec![ - PhrasePart::Word("grow"), - operand("clone", concrete(material(named("Clone"))), take), - PhrasePart::Word("at"), - PhrasePart::Quantity { - name: "temperature", - signed: true, - units: &["C"], - }, - PhrasePart::Word("for"), - PhrasePart::Quantity { - name: "duration", - signed: false, - units: &["h"], - }, + PhrasePart::word("culture"), + operand("clone", concrete(strain("isolated")), take), + PhrasePart::word("at"), + PhrasePart::quantity("temperature", true, &["C"]), + PhrasePart::word("for"), + PhrasePart::quantity("duration", false, &["h"]), ], - results: vec![result("culture", concrete(material(named("Culture"))))], + inert: Vec::new(), + results: vec![result("culture", concrete(strain("grown")))], }, ActionContractSpec { - operation: "std.lab.plasmid.purify", + operation: "std.lab.plasmid.purify".to_owned(), phrase: vec![ - PhrasePart::Word("purify"), - operand("culture", concrete(material(named("Culture"))), take), + PhrasePart::word("purify"), + operand("culture", concrete(strain("grown")), take), ], + inert: Vec::new(), results: vec![result("plasmid", concrete(material(named("Plasmid"))))], }, ActionContractSpec { - operation: "std.lab.plasmid.split", + operation: "std.lab.plasmid.split".to_owned(), phrase: vec![ - PhrasePart::Word("split"), + PhrasePart::word("split"), operand("material", concrete(material(named("Plasmid"))), take), ], + inert: Vec::new(), results: vec![ - result("retained", ContractType::SameAs("material")), - result("aliquot", ContractType::SameAs("material")), + result("retained", ContractType::SameAs("material".to_owned())), + result("aliquot", ContractType::SameAs("material".to_owned())), ], }, ActionContractSpec { - operation: "std.lab.plasmid.sequence", + operation: "std.lab.plasmid.sequence".to_owned(), phrase: vec![ - PhrasePart::Word("sequence"), + PhrasePart::word("sequence"), operand("aliquot", concrete(material(named("Plasmid"))), take), ], + inert: Vec::new(), results: vec![result("result", concrete(named("SequenceCheck")))], }, ActionContractSpec { - operation: "std.lab.plasmid.quantify", + operation: "std.lab.plasmid.quantify".to_owned(), phrase: vec![ - PhrasePart::Word("quantify"), + PhrasePart::word("quantify"), operand("material", concrete(material(named("Plasmid"))), borrow), ], + inert: Vec::new(), results: vec![result("evidence", concrete(named("Evidence")))], }, ActionContractSpec { - operation: "std.lab.plasmid.store", + operation: "std.lab.plasmid.store".to_owned(), phrase: vec![ - PhrasePart::Word("store"), + PhrasePart::word("store"), operand("material", concrete(material(named("Plasmid"))), take), - PhrasePart::Word("at"), - PhrasePart::Quantity { - name: "temperature", - signed: true, - units: &["C"], - }, + PhrasePart::word("at"), + PhrasePart::quantity("temperature", true, &["C"]), ], - results: vec![result("material", ContractType::SameAs("material"))], + inert: Vec::new(), + results: vec![result( + "material", + ContractType::SameAs("material".to_owned()), + )], }, ActionContractSpec { - operation: "std.lab.plasmid.dispose", + operation: "std.lab.plasmid.dispose".to_owned(), phrase: vec![ - PhrasePart::Word("dispose"), + PhrasePart::word("dispose"), operand("material", ContractType::AnyMaterial, take), ], + inert: Vec::new(), results: Vec::new(), }, ]; diff --git a/crates/lab-language/src/standard_library/manifest.rs b/crates/lab-language/src/standard_library/manifest.rs index 8a80e0cc..c1ce1e84 100644 --- a/crates/lab-language/src/standard_library/manifest.rs +++ b/crates/lab-language/src/standard_library/manifest.rs @@ -60,6 +60,13 @@ pub enum Export { /// A part types can play. It has no values, so it may bound a type /// parameter and may never be the type of anything. Role { name: String, documentation: String }, + /// How a kind's materials are classified by the state they are in. + Facet { + name: String, + documentation: String, + subject: String, + states: Vec, + }, Value { name: String, documentation: String, @@ -314,6 +321,45 @@ fn authored_export(name: &str, export: &ModuleExport) -> Option { .map_or_else(String::new, |output| output.r#type.display_name()), }) } - ExportKind::Action => None, + ExportKind::Facet => { + let surface = export.facet.as_ref()?; + Some(Export::Facet { + name: name.to_owned(), + documentation, + subject: surface.subject.display_name(), + states: surface + .states + .iter() + .map(|state| state.name.clone()) + .collect(), + }) + } + ExportKind::Action => { + let surface = export.action.as_ref()?; + Some(Export::Action { + name: name.to_owned(), + documentation, + phrase: surface + .phrase + .iter() + .map(|token| match token { + crate::checked::CheckedPhraseToken::Word(word) => word.clone(), + crate::checked::CheckedPhraseToken::Hole(operand) => { + format!("<{operand}>") + } + }) + .collect(), + optional: Vec::new(), + results: surface + .results + .iter() + .map(|result| Field { + name: result.name.clone(), + r#type: result.r#type.display_name(), + optional: false, + }) + .collect(), + }) + } } } diff --git a/crates/lab-language/src/standard_library/mod.rs b/crates/lab-language/src/standard_library/mod.rs index d789d8dc..af5a6c74 100644 --- a/crates/lab-language/src/standard_library/mod.rs +++ b/crates/lab-language/src/standard_library/mod.rs @@ -14,7 +14,7 @@ mod prelude; pub(crate) use catalog::{ ConstructorSpec, PureFunctionSpec, StandardLibrary, StandardModule, TypeSpec, }; -pub(crate) use contract::{ActionContractSpec, ContractType, Lineage, PhrasePart}; +pub(crate) use contract::{ActionContractSpec, ContractType, Lineage, PhrasePart, ResultSpec}; pub(crate) fn manifest() -> manifest::Library { manifest::library() diff --git a/crates/lab-language/src/standard_library/prelude.rs b/crates/lab-language/src/standard_library/prelude.rs index 6ed5c93e..91848829 100644 --- a/crates/lab-language/src/standard_library/prelude.rs +++ b/crates/lab-language/src/standard_library/prelude.rs @@ -11,15 +11,21 @@ pub(in crate::standard_library) fn modules() -> Vec { TypeSpec::nominal("Accepted").parameters(1), TypeSpec::nominal("Antibiotic"), TypeSpec::nominal("Backbone"), + TypeSpec::nominal("Buffer") + .implements(["Solution"]) + .documented("A salt solution cells are washed and resuspended in."), TypeSpec::nominal("CDS").parameters(1), TypeSpec::nominal("Chassis").documented("A host organism that carries engineered DNA."), TypeSpec::nominal("Circuit").parameters(2), - TypeSpec::nominal("Clone"), - TypeSpec::nominal("CloneSet") - .with_fields([("highest_confidence", Ty::material(named("Clone")))]), + TypeSpec::nominal("CloneSet").with_fields([( + "highest_confidence", + Ty::material(Ty::InState( + Box::new(named("Strain")), + "isolated".to_owned(), + )), + )]), TypeSpec::nominal("Colonies").with_fields([("count", Ty::Integer)]), TypeSpec::nominal("ColonyMap").with_fields([("isolated", named("Colonies"))]), - TypeSpec::nominal("Culture"), TypeSpec::nominal("DNA"), TypeSpec::nominal("Duration"), TypeSpec::nominal("Evidence").implements(["Evidential"]), @@ -30,8 +36,10 @@ pub(in crate::standard_library) fn modules() -> Vec { TypeSpec::nominal("Image"), TypeSpec::nominal("List").parameters(1), TypeSpec::nominal("Material").parameters(1), + TypeSpec::nominal("Medium") + .implements(["Solution"]) + .documented("What an organism is grown in or on."), TypeSpec::nominal("Part"), - TypeSpec::nominal("Plate"), TypeSpec::nominal("Plasmid") .with_fields([ ("topology", named("Topology")), @@ -51,6 +59,8 @@ pub(in crate::standard_library) fn modules() -> Vec { TypeSpec::nominal("RestrictionEnzyme"), TypeSpec::nominal("Screening").with_fields([("clones", named("CloneSet"))]), TypeSpec::role("Signal").documented("A molecule or condition a circuit responds to."), + TypeSpec::role("Solution") + .documented("A poured solution: a buffer or a medium a verb pours the same way."), TypeSpec::nominal("Strain") .with_fields([ ("chassis", named("Chassis")), diff --git a/crates/lab-language/src/type_system.rs b/crates/lab-language/src/type_system.rs index d89faabc..bce6cb95 100644 --- a/crates/lab-language/src/type_system.rs +++ b/crates/lab-language/src/type_system.rs @@ -22,6 +22,19 @@ pub(crate) enum Ty { /// constrained to a role. `Circuit` is a circuit /// driven by some signal nobody may name again. Any(String), + /// A measurement of a stated thing, in whatever unit it was written in. + /// + /// A field asks for this where it holds measurements an author chose the + /// units of: a recipe carries grams per litre and millimolar together, and + /// pinning one unit would refuse the other. Each value still names its own + /// unit, so nothing converts on its own. + Measuring(String), + /// A type argument narrowed to one facet state. + /// + /// This only ever appears as an argument, the way `Any` does, so a material + /// in a state is still `Material<..>` to the passes that detect one by its + /// outermost name. Ownership and linearity are unaffected by a narrowing. + InState(Box, String), Integer, Decimal, String, @@ -66,6 +79,8 @@ impl fmt::Display for Ty { Self::List(element) => write!(formatter, "List<{element}>"), Self::Quantity(unit) => write!(formatter, "Quantity<{unit}>"), Self::Any(role) => write!(formatter, "any {role}"), + Self::Measuring(dimension) => write!(formatter, "Quantity"), + Self::InState(subject, state) => write!(formatter, "{subject} is {state}"), Self::Integer => formatter.write_str("Integer"), Self::Decimal => formatter.write_str("Decimal"), Self::String => formatter.write_str("String"), @@ -93,6 +108,13 @@ pub(crate) fn to_checked_type(ty: &Ty) -> CheckedType { Ty::Decimal => CheckedType::Decimal, Ty::String => CheckedType::String, Ty::Any(role) => CheckedType::Any { role: role.clone() }, + Ty::Measuring(dimension) => CheckedType::Measuring { + dimension: dimension.clone(), + }, + Ty::InState(subject, state) => CheckedType::InState { + subject: Box::new(to_checked_type(subject)), + state: state.clone(), + }, Ty::Bool => CheckedType::Bool, Ty::None => CheckedType::None, Ty::EmptyList => CheckedType::List { @@ -116,6 +138,10 @@ pub(crate) fn from_checked_type(ty: &CheckedType) -> Ty { CheckedType::List { element } => Ty::List(Box::new(from_checked_type(element))), CheckedType::Quantity { unit } => Ty::Quantity(unit.clone()), CheckedType::Any { role } => Ty::Any(role.clone()), + CheckedType::Measuring { dimension } => Ty::Measuring(dimension.clone()), + CheckedType::InState { subject, state } => { + Ty::InState(Box::new(from_checked_type(subject)), state.clone()) + } CheckedType::Integer => Ty::Integer, CheckedType::Decimal => Ty::Decimal, CheckedType::String => Ty::String, @@ -157,12 +183,32 @@ pub(crate) fn compatible(roles: &RoleTable, actual: &Ty, expected: &Ty) -> bool // `Circuit` become `Circuit` without a // separate rule, and it never runs in the other direction. Ty::Any(role) => plays_role(roles, actual, role), + // A measurement fits a field asking for what it measures. This runs one + // way: a field naming a unit still refuses every other unit, so the + // thousandfold error 0025 refuses is refused here too. + Ty::Measuring(dimension) => matches!( + (actual, crate::units::Dimension::named(dimension)), + (Ty::Quantity(unit), Some(wanted)) + if crate::units::measured(unit).is_some_and(|it| it.dimension == wanted) + ), Ty::Union(alternatives) => alternatives.iter().any(|ty| compatible(roles, actual, ty)), Ty::List(expected) => match actual { Ty::List(actual) => compatible(roles, actual, expected), Ty::EmptyList => true, _ => false, }, + // Narrowing runs one way. A material known to be in a state may be used + // where any state of that subject is accepted, because knowing more is + // never a problem. The reverse is what this exists to refuse: an + // unnarrowed material cannot stand in where a state is required, which + // is how transforming into cells nobody made competent is caught. + Ty::InState(expected_subject, expected_state) => match actual { + Ty::InState(actual_subject, actual_state) => { + actual_state == expected_state + && compatible(roles, actual_subject, expected_subject) + } + _ => false, + }, Ty::Named(expected_name, expected_args) => match actual { Ty::Named(actual_name, actual_args) => { actual_name == expected_name @@ -172,6 +218,7 @@ pub(crate) fn compatible(roles: &RoleTable, actual: &Ty, expected: &Ty) -> bool .zip(expected_args) .all(|(actual, expected)| compatible(roles, actual, expected)) } + Ty::InState(actual_subject, _) => compatible(roles, actual_subject, expected), _ => false, }, _ => false, @@ -205,10 +252,21 @@ pub(crate) fn common_type(roles: &RoleTable, left: Ty, right: Ty) -> Ty { Ty::Union(alternatives) } +/// Whether two types may be compared or added to one another. +/// +/// Two quantities qualify only when they are measured in the same unit. Letting +/// any quantity meet any other made `20 uL + 5 mL` a microlitre volume and +/// `volume > 5 mL` a question worth asking of microlitres, which is the +/// thousandfold error [0025](../../docs/language/decisions/0025-quantity-types.md) +/// refuses when the same two units meet across an assignment. pub(crate) fn comparable(roles: &RoleTable, left: &Ty, right: &Ty) -> bool { - compatible(roles, left, right) - || compatible(roles, right, left) - || matches!((left, right), (Ty::Quantity(_), Ty::Quantity(_))) + compatible(roles, left, right) || compatible(roles, right, left) +} + +/// Whether a type counts without being measured in anything, and so may scale a +/// quantity. +pub(crate) fn dimensionless(ty: &Ty) -> bool { + matches!(ty, Ty::Integer | Ty::Decimal) } /// What each type parameter was inferred as, and the operand that fixed it. diff --git a/crates/lab-language/src/units.rs b/crates/lab-language/src/units.rs new file mode 100644 index 00000000..ef9aaeb8 --- /dev/null +++ b/crates/lab-language/src/units.rs @@ -0,0 +1,428 @@ +//! What a unit measures, and how one unit relates to another measuring the same +//! thing. +//! +//! A unit is written as an ordinary word, so any word is a unit and the +//! vocabulary stays open. A word this table knows also carries a dimension: what +//! it measures, and how far its scale sits from the canonical unit for that +//! dimension. That is what lets `10 g/L * 500 mL` be `5 g` rather than a +//! quantity in neither operand's unit, and what lets `12 kb` be compared with a +//! length in base pairs. +//! +//! A word the table does not know measures something this compiler has no +//! opinion about. It can still be written, held in a field, and compared with +//! itself; it cannot be converted or composed, because nothing here knows what +//! it would convert to. + +use std::fmt; + +/// What a measurement measures, as powers of the things a laboratory counts. +/// +/// Volume is its own base rather than a cube of length. A laboratory measures +/// in litres, not in cubic metres, and deriving one from the other would make +/// every volume carry an exponent nobody wrote. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub(crate) struct Dimension { + mass: i8, + volume: i8, + amount: i8, + length: i8, + duration: i8, + temperature: i8, + /// Things counted rather than measured: colonies, base pairs, cycles. + count: i8, +} + +impl Dimension { + const DIMENSIONLESS: Self = Self::of([0, 0, 0, 0, 0, 0, 0]); + const MASS: Self = Self::of([1, 0, 0, 0, 0, 0, 0]); + const VOLUME: Self = Self::of([0, 1, 0, 0, 0, 0, 0]); + const AMOUNT: Self = Self::of([0, 0, 1, 0, 0, 0, 0]); + const LENGTH: Self = Self::of([0, 0, 0, 1, 0, 0, 0]); + const DURATION: Self = Self::of([0, 0, 0, 0, 1, 0, 0]); + const TEMPERATURE: Self = Self::of([0, 0, 0, 0, 0, 1, 0]); + const COUNT: Self = Self::of([0, 0, 0, 0, 0, 0, 1]); + /// Mass in a volume, which is what a medium recipe is written in. + const CONCENTRATION: Self = Self::of([1, -1, 0, 0, 0, 0, 0]); + /// Amount in a volume, which is what a buffer is written in. + const MOLARITY: Self = Self::of([0, -1, 1, 0, 0, 0, 0]); + + const fn of(powers: [i8; 7]) -> Self { + Self { + mass: powers[0], + volume: powers[1], + amount: powers[2], + length: powers[3], + duration: powers[4], + temperature: powers[5], + count: powers[6], + } + } + + fn combined(self, other: Self, sign: i8) -> Self { + Self { + mass: self.mass + sign * other.mass, + volume: self.volume + sign * other.volume, + amount: self.amount + sign * other.amount, + length: self.length + sign * other.length, + duration: self.duration + sign * other.duration, + temperature: self.temperature + sign * other.temperature, + count: self.count + sign * other.count, + } + } + + pub(crate) fn times(self, other: Self) -> Self { + self.combined(other, 1) + } + + pub(crate) fn over(self, other: Self) -> Self { + self.combined(other, -1) + } + + pub(crate) fn is_dimensionless(self) -> bool { + self == Self::DIMENSIONLESS + } + + /// The name this dimension is written as where a field asks for one. + /// + /// Only the dimensions a person names have one. A derived dimension such as + /// mass over volume is a real answer for arithmetic to produce and not + /// something a schema asks for, so it has no name and is described by the + /// unit it came out in. + pub(crate) fn name(self) -> Option<&'static str> { + Some(match self { + Self::MASS => "Mass", + Self::VOLUME => "Volume", + Self::AMOUNT => "Amount", + Self::LENGTH => "Length", + Self::DURATION => "Duration", + Self::TEMPERATURE => "Temperature", + Self::COUNT => "Count", + Self::CONCENTRATION => "Concentration", + Self::MOLARITY => "Molarity", + _ => return None, + }) + } + + /// This dimension split into what it is and what it is per, when it is + /// exactly one of each. Mass over volume splits; mass over volume squared + /// does not. + fn as_ratio(self) -> Option<(Self, Self)> { + let mut numerator = [0i8; 7]; + let mut denominator = [0i8; 7]; + for (index, power) in self.powers().into_iter().enumerate() { + match power { + 0 => {} + 1 => numerator[index] = 1, + -1 => denominator[index] = 1, + _ => return None, + } + } + let (numerator, denominator) = (Self::of(numerator), Self::of(denominator)); + (!numerator.is_dimensionless() && !denominator.is_dimensionless()) + .then_some((numerator, denominator)) + } + + fn powers(self) -> [i8; 7] { + [ + self.mass, + self.volume, + self.amount, + self.length, + self.duration, + self.temperature, + self.count, + ] + } + + /// The dimension a field names, if that word names one. + pub(crate) fn named(name: &str) -> Option { + Some(match name { + "Mass" => Self::MASS, + "Volume" => Self::VOLUME, + "Amount" => Self::AMOUNT, + "Length" => Self::LENGTH, + "Duration" => Self::DURATION, + "Temperature" => Self::TEMPERATURE, + "Count" => Self::COUNT, + "Concentration" => Self::CONCENTRATION, + "Molarity" => Self::MOLARITY, + _ => return None, + }) + } +} + +/// What one unit measures and where its scale sits. +/// +/// `decade` is the power of ten that takes this unit to the canonical unit for +/// its dimension: a nanogram is `-9` because a nanogram is `10^-9` grams. Powers +/// of ten keep every conversion exact, which decimal magnitudes then carry +/// without rounding. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Measured { + pub dimension: Dimension, + pub decade: i32, +} + +impl Measured { + const fn new(dimension: Dimension, decade: i32) -> Self { + Self { dimension, decade } + } +} + +/// Units whose meaning this compiler knows, with the canonical unit of each +/// dimension at decade zero. +/// +/// Celsius is deliberately absent. Converting a temperature is an offset rather +/// than a scale, so a table of powers of ten would get it wrong, and a +/// laboratory writes degrees Celsius and means them. +const TABLE: &[(&str, Measured)] = &[ + ("g", Measured::new(Dimension::MASS, 0)), + ("kg", Measured::new(Dimension::MASS, 3)), + ("mg", Measured::new(Dimension::MASS, -3)), + ("ug", Measured::new(Dimension::MASS, -6)), + ("ng", Measured::new(Dimension::MASS, -9)), + ("pg", Measured::new(Dimension::MASS, -12)), + ("L", Measured::new(Dimension::VOLUME, 0)), + ("mL", Measured::new(Dimension::VOLUME, -3)), + ("uL", Measured::new(Dimension::VOLUME, -6)), + ("nL", Measured::new(Dimension::VOLUME, -9)), + ("pL", Measured::new(Dimension::VOLUME, -12)), + ("mol", Measured::new(Dimension::AMOUNT, 0)), + ("mmol", Measured::new(Dimension::AMOUNT, -3)), + ("umol", Measured::new(Dimension::AMOUNT, -6)), + ("nmol", Measured::new(Dimension::AMOUNT, -9)), + ("pmol", Measured::new(Dimension::AMOUNT, -12)), + ("fmol", Measured::new(Dimension::AMOUNT, -15)), + ("m", Measured::new(Dimension::LENGTH, 0)), + ("mm", Measured::new(Dimension::LENGTH, -3)), + ("um", Measured::new(Dimension::LENGTH, -6)), + ("nm", Measured::new(Dimension::LENGTH, -9)), + ("s", Measured::new(Dimension::DURATION, 0)), + ("min", Measured::new(Dimension::DURATION, 0)), + ("h", Measured::new(Dimension::DURATION, 0)), + ("d", Measured::new(Dimension::DURATION, 0)), + ("M", Measured::new(Dimension::MOLARITY, 0)), + ("mM", Measured::new(Dimension::MOLARITY, -3)), + ("uM", Measured::new(Dimension::MOLARITY, -6)), + ("nM", Measured::new(Dimension::MOLARITY, -9)), + ("bp", Measured::new(Dimension::COUNT, 0)), + ("kb", Measured::new(Dimension::COUNT, 3)), + ("Mb", Measured::new(Dimension::COUNT, 6)), + ("cfu", Measured::new(Dimension::COUNT, 0)), +]; + +/// The units of one dimension whose scales are not powers of ten. +/// +/// An hour is 3600 seconds, not `10^n` seconds, so duration cannot ride the +/// decade table. These convert against each other exactly and against nothing +/// else. +const DURATIONS: &[(&str, u64)] = &[("s", 1), ("min", 60), ("h", 3_600), ("d", 86_400)]; + +/// What a unit measures, reading a compound unit as its numerator over its +/// denominator. +pub(crate) fn measured(unit: &str) -> Option { + if let Some((numerator, denominator)) = unit.split_once('/') { + let numerator = simple(numerator)?; + let denominator = simple(denominator)?; + return Some(Measured { + dimension: numerator.dimension.over(denominator.dimension), + decade: numerator.decade - denominator.decade, + }); + } + simple(unit) +} + +fn simple(unit: &str) -> Option { + TABLE + .iter() + .find(|(name, _)| *name == unit) + .map(|(_, measured)| *measured) +} + +/// How many of `to` one `from` is, when both measure the same thing. +/// +/// The answer is a ratio of whole numbers so a magnitude converts exactly. A +/// unit this table does not know, or two units measuring different things, have +/// no ratio and convert to nothing. +pub(crate) fn ratio(from: &str, to: &str) -> Option<(u64, u64)> { + if from == to { + return Some((1, 1)); + } + let (source, target) = (measured(from)?, measured(to)?); + if source.dimension != target.dimension { + return None; + } + // Durations scale by sixties rather than by tens. + if source.dimension == Dimension::DURATION { + let seconds = |unit: &str| { + DURATIONS + .iter() + .find(|(name, _)| *name == unit) + .map(|it| it.1) + }; + return Some((seconds(from)?, seconds(to)?)); + } + let decades = source.decade - target.decade; + let power = 10u64.checked_pow(decades.unsigned_abs())?; + Some(if decades >= 0 { (power, 1) } else { (1, power) }) +} + +/// The unit a derived measurement is expressed in. +/// +/// A product or a quotient lands in the canonical unit of whatever it came out +/// measuring, so the result of `10 g/L * 500 mL` is grams and not some scaled +/// unit nobody wrote. Where the dimension has no canonical spelling, the +/// arithmetic has no unit to report. +pub(crate) fn canonical(dimension: Dimension) -> Option { + if dimension.is_dimensionless() { + return None; + } + if let Some(unit) = base_unit(dimension) { + return Some(unit.to_owned()); + } + // A derived dimension is written as the canonical unit of what it is over + // the canonical unit of what it is per, which is how mass over volume comes + // back out as `g/L`. Anything more layered than that has no spelling here, + // and saying so beats inventing one. + let (numerator, denominator) = dimension.as_ratio()?; + Some(format!( + "{}/{}", + base_unit(numerator)?, + base_unit(denominator)? + )) +} + +/// The canonical unit of a dimension that is one base thing, unscaled. +fn base_unit(dimension: Dimension) -> Option<&'static str> { + TABLE + .iter() + .find(|(_, measured)| measured.dimension == dimension && measured.decade == 0) + .map(|(name, _)| *name) +} + +impl fmt::Display for Dimension { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.name() { + Some(name) => formatter.write_str(name), + None => formatter.write_str("a derived measurement"), + } + } +} + +/// An exact decimal: `digits * 10^exponent`. +/// +/// Quantities compose by multiplying magnitudes and adding the powers of ten +/// their units sit at, and both stay exact when the magnitude never becomes a +/// float. A recipe scaled to a batch is weighed out on a balance, so a rounding +/// here is a rounding there. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Decimal { + digits: i128, + exponent: i32, +} + +impl Decimal { + /// Read a plain decimal, which is the only spelling that reaches here: an + /// exponent literal was written out where it was lexed. + pub(crate) fn parse(text: &str) -> Option { + let (sign, rest) = match text.strip_prefix('-') { + Some(rest) => (-1, rest), + None => (1, text.strip_prefix('+').unwrap_or(text)), + }; + let (whole, fraction) = rest.split_once('.').unwrap_or((rest, "")); + if whole.is_empty() && fraction.is_empty() { + return None; + } + if !whole + .chars() + .chain(fraction.chars()) + .all(|c| c.is_ascii_digit()) + { + return None; + } + let digits: i128 = format!("{whole}{fraction}").parse().ok()?; + Some(Self { + digits: sign * digits, + exponent: -(fraction.len() as i32), + }) + } + + pub(crate) fn times(self, other: Self) -> Option { + Some(Self { + digits: self.digits.checked_mul(other.digits)?, + exponent: self.exponent.checked_add(other.exponent)?, + }) + } + + /// Divide, when the quotient terminates. + /// + /// A third of a gram has no exact decimal, and rounding one silently is how + /// a balance ends up reading something nobody wrote. Refusing is the honest + /// answer. + pub(crate) fn over(self, other: Self) -> Option { + if other.digits == 0 { + return None; + } + // Lengthen the numerator until the division comes out whole, within the + // range a decimal magnitude can hold. + let mut digits = self.digits; + let mut exponent = self.exponent; + for _ in 0..38 { + if digits % other.digits == 0 { + return Some(Self { + digits: digits / other.digits, + exponent: exponent.checked_sub(other.exponent)?, + }); + } + digits = digits.checked_mul(10)?; + exponent = exponent.checked_sub(1)?; + } + None + } + + pub(crate) fn shifted(self, decades: i32) -> Option { + Some(Self { + digits: self.digits, + exponent: self.exponent.checked_add(decades)?, + }) + } + + /// Multiply by a whole ratio, which is how a duration converts. + pub(crate) fn scaled(self, numerator: u64, denominator: u64) -> Option { + let scaled = Self { + digits: self.digits.checked_mul(i128::from(numerator))?, + exponent: self.exponent, + }; + scaled.over(Self { + digits: i128::from(denominator), + exponent: 0, + }) + } +} + +impl fmt::Display for Decimal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.exponent >= 0 { + return write!( + formatter, + "{}{}", + self.digits, + "0".repeat(self.exponent as usize) + ); + } + let places = self.exponent.unsigned_abs() as usize; + let sign = if self.digits < 0 { "-" } else { "" }; + let digits = self.digits.unsigned_abs().to_string(); + let digits = if digits.len() <= places { + format!("{}{digits}", "0".repeat(places - digits.len() + 1)) + } else { + digits + }; + let point = digits.len() - places; + let fraction = digits[point..].trim_end_matches('0'); + if fraction.is_empty() { + return write!(formatter, "{sign}{}", &digits[..point]); + } + write!(formatter, "{sign}{}.{fraction}", &digits[..point]) + } +} diff --git a/crates/lab-package/src/lib.rs b/crates/lab-package/src/lib.rs index 5eecae4f..d36efb98 100644 --- a/crates/lab-package/src/lib.rs +++ b/crates/lab-package/src/lib.rs @@ -13,7 +13,7 @@ pub use manifest::{ }; pub use package::{ DiscoveredRoot, LabPackage, LabWorkspace, PackageError, PackageSource, SbolSyntax, - SourceLanguage, + SourceLanguage, source_generator, }; pub const MANIFEST_FILE: &str = "lab.toml"; diff --git a/crates/lab-package/src/manifest.rs b/crates/lab-package/src/manifest.rs index 07050552..6522eacf 100644 --- a/crates/lab-package/src/manifest.rs +++ b/crates/lab-package/src/manifest.rs @@ -81,6 +81,12 @@ pub struct PackageMetadata { #[serde(deny_unknown_fields)] pub struct BuildMetadata { pub entry: Option, + /// A command that regenerates this package's sources, run from the package + /// root before the CLI compiles. A workspace whose Lab is emitted by + /// another frontend, such as the Python SDK, names its exporter here so + /// `lab check`, `lab plan`, and `lab build` always compile what the source + /// of truth says. + pub generate: Option, } /// Portable Method documents contributed by this package. diff --git a/crates/lab-package/src/package.rs b/crates/lab-package/src/package.rs index 9a9cdfae..03cb584f 100644 --- a/crates/lab-package/src/package.rs +++ b/crates/lab-package/src/package.rs @@ -172,6 +172,22 @@ pub enum PackageError { NotAPackage { path: PathBuf }, } +/// The source generator of the package nearest `start`: the command its +/// manifest declares and the package root to run it from. +/// +/// This reads only the manifest, because the whole point of a generator is +/// that the sources it writes may not exist yet, and loading the package +/// validates its entry against the sources on disk. +pub fn source_generator( + start: impl AsRef, +) -> Result, PackageError> { + let root = find_manifest_directory(start.as_ref())?; + let LabManifest::Package(manifest) = read_manifest(&root)? else { + return Ok(None); + }; + Ok(manifest.build.generate.map(|command| (root, command))) +} + fn find_manifest_directory(start: &Path) -> Result { let mut directory = if start.is_file() { start @@ -282,6 +298,14 @@ impl LabPackage { .find(|source| normalize_relative(&source.relative_path) == normalized_entry) } + /// The runnable programs: every source under `src/programs/`, in path + /// order. Each is an entry a build can be rooted at, named by its file. + pub fn program_sources(&self) -> impl Iterator { + self.sources.iter().filter(|source| { + normalize_relative(&source.relative_path).starts_with(Path::new("src/programs")) + }) + } + pub fn module_graph(&self) -> Result { ModuleGraph::new( &self.manifest, diff --git a/crates/lab-project/src/facility.rs b/crates/lab-project/src/facility.rs index f060e424..51807bd9 100644 --- a/crates/lab-project/src/facility.rs +++ b/crates/lab-project/src/facility.rs @@ -163,7 +163,7 @@ impl LabProject { .filter(|module| program_packages.contains(&module.package)) .map(|module| &module.module) .collect::>(); - plan_modules_with_inventory(package, &modules, methods, inventory) + plan_modules_with_inventory(package, &modules, methods, inventory, None) } /// Plans with the standard and package-contributed Methods captured during compilation. @@ -173,6 +173,36 @@ impl LabProject { ) -> Result { self.plan_facility(compiled, &compiled.methods) } + + /// Plans one program of the default runnable package: the build the named + /// entry module's `main` reaches through workflow calls. What the workspace + /// declares beyond that is a library and stays unplanned. + pub fn plan_facility_program( + &self, + compiled: &CompiledProject, + entry_module: &str, + ) -> Result { + let package = self.default_package(); + let inventory = load_package_inventory(package)?.ok_or_else(|| { + FacilityProjectError::MissingInventory { + package: package.manifest.package.name.clone(), + } + })?; + let program_packages = self.program_packages(); + let modules = compiled + .modules + .iter() + .filter(|module| program_packages.contains(&module.package)) + .map(|module| &module.module) + .collect::>(); + plan_modules_with_inventory( + package, + &modules, + &compiled.methods, + inventory, + Some(entry_module), + ) + } } /// Plans an explicitly supplied, already checked program against one package's facility context. @@ -189,7 +219,7 @@ pub fn plan_modules_for_package( load_package_inventory(package)?.ok_or_else(|| FacilityProjectError::MissingInventory { package: package.manifest.package.name.clone(), })?; - plan_modules_with_inventory(package, modules, methods, inventory) + plan_modules_with_inventory(package, modules, methods, inventory, None) } fn plan_modules_with_inventory( @@ -197,9 +227,10 @@ fn plan_modules_with_inventory( modules: &[&lab_language::CheckedModule], methods: &MethodRegistry, inventory: InventorySnapshot, + entry: Option<&str>, ) -> Result { - let portable = - PortableLairProgram::lower_program(modules).map_err(FacilityProjectError::PortableLair)?; + let portable = PortableLairProgram::lower_program_rooted(modules, entry) + .map_err(FacilityProjectError::PortableLair)?; let refined = portable .refine_methods(methods) .map_err(FacilityProjectError::RefinedLair)?; diff --git a/crates/lab-python/pyproject.toml b/crates/lab-python/pyproject.toml index e7c30aec..95c289a5 100644 --- a/crates/lab-python/pyproject.toml +++ b/crates/lab-python/pyproject.toml @@ -106,7 +106,7 @@ follow_imports = "skip" # until there is one a program written in the object model is checked by the # Lab compiler rather than by mypy. The package itself stays strict. [[tool.mypy.overrides]] -module = ["programs.reporter.*"] +module = ["programs.reporter.*", "programs.competence"] disable_error_code = ["call-arg", "attr-defined", "no-any-return", "return"] [tool.pytest.ini_options] diff --git a/crates/lab-python/python/lab/__init__.py b/crates/lab-python/python/lab/__init__.py index c53abbb3..d3553dcc 100644 --- a/crates/lab-python/python/lab/__init__.py +++ b/crates/lab-python/python/lab/__init__.py @@ -88,9 +88,9 @@ from .plasmid import ( assemble, capture, + culture, dilute, dispose, - grow, pick, plate, provision, @@ -203,12 +203,12 @@ def compile_lab_module(source: str) -> dict[str, Any]: "circuit", "circular", "compile_lab_module", + "culture", "detect_colonies", "dilute", "dispose", "dna", "expression", - "grow", "inconclusive_sequence", "induced", "layout", diff --git a/crates/lab-python/python/lab/_expressions.py b/crates/lab-python/python/lab/_expressions.py index b655cfaf..dec11f98 100644 --- a/crates/lab-python/python/lab/_expressions.py +++ b/crates/lab-python/python/lab/_expressions.py @@ -344,6 +344,10 @@ def operand(self) -> str: def expression(value: object) -> Expression: """The Lab expression a Python value stands for.""" + from ._types import state_name + + if (state := state_name(value)) is not None: + return Reference(state) if isinstance(value, Expression): return value if isinstance(value, Expressible): diff --git a/crates/lab-python/python/lab/_prelude.py b/crates/lab-python/python/lab/_prelude.py index 25d9bfd7..062ad43d 100644 --- a/crates/lab-python/python/lab/_prelude.py +++ b/crates/lab-python/python/lab/_prelude.py @@ -19,13 +19,12 @@ "Accepted", "Antibiotic", "Backbone", + "Buffer", "Chassis", "Circuit", - "Clone", "CloneSet", "Colonies", "ColonyMap", - "Culture", "Duration", "Event", "Evidence", @@ -34,9 +33,9 @@ "Image", "List", "Material", + "Medium", "Part", "Plasmid", - "Plate", "Promoter", "Protein", "Reason", @@ -45,6 +44,7 @@ "RestrictionEnzyme", "Screening", "Signal", + "Solution", "Strain", "Topology", "WorkflowContext", @@ -74,6 +74,12 @@ class Backbone(LabType): __lab_uses__ = () +class Buffer(LabType): + """A salt solution cells are washed and resuspended in.""" + + __lab_uses__ = () + + class CDS(LabType, Generic[_T1]): __lab_uses__ = () @@ -88,23 +94,15 @@ class Circuit(LabType, Generic[_T1, _T2]): __lab_uses__ = () -class Clone(LabType): +class CloneSet(LabConstructor): __lab_uses__ = () -class CloneSet(LabType): +class Colonies(LabConstructor): __lab_uses__ = () -class Colonies(LabType): - __lab_uses__ = () - - -class ColonyMap(LabType): - __lab_uses__ = () - - -class Culture(LabType): +class ColonyMap(LabConstructor): __lab_uses__ = () @@ -150,15 +148,17 @@ class Material(LabType, Generic[_T1]): __lab_uses__ = () -class Part(LabType): +class Medium(LabType): + """What an organism is grown in or on.""" + __lab_uses__ = () -class Plate(LabType): +class Part(LabType): __lab_uses__ = () -class Plasmid(LabType): +class Plasmid(LabConstructor): """A backend-neutral plasmid design.""" __lab_uses__ = () @@ -193,7 +193,7 @@ class RestrictionEnzyme(LabType): __lab_uses__ = () -class Screening(LabType): +class Screening(LabConstructor): __lab_uses__ = () @@ -204,7 +204,14 @@ class Signal(LabRole): __lab_uses__ = () -class Strain(LabType): +class Solution(LabRole): + """A poured solution: a buffer or a medium a verb pours the same way.""" + + __lab_role__ = "Solution" + __lab_uses__ = () + + +class Strain(LabConstructor): """A chassis carrying a defined set of plasmid designs.""" __lab_uses__ = () @@ -214,7 +221,7 @@ class Topology(LabType): __lab_uses__ = () -class WorkflowContext(LabType): +class WorkflowContext(LabConstructor): __lab_uses__ = () diff --git a/crates/lab-python/python/lab/_types.py b/crates/lab-python/python/lab/_types.py index b4862560..1c788800 100644 --- a/crates/lab-python/python/lab/_types.py +++ b/crates/lab-python/python/lab/_types.py @@ -66,6 +66,32 @@ class LabRole(LabType): __lab_role__: str = "" +class LabState: + """One state a facet admits, mirrored as a class so it may be written in an + annotation. + + `inoculated[Medium]` is `Medium is inoculated`. Lab spells the narrowing + with `is`, which Python reads as identity, and a type checker will not + accept a call or a bare variable in an annotation. A generic class is what + is left, and it is what roles are already mirrored as. + """ + + #: The Lab name of the state, read back where a declaration states it. + __lab_state__: str = "" + #: The Lab modules a program naming this state has to import. + __lab_uses__: tuple[str, ...] = () + + +def state_name(annotation: object) -> str | None: + """The state a class names, if it names one.""" + + return ( + annotation.__lab_state__ + if isinstance(annotation, type) and issubclass(annotation, LabState) + else None + ) + + class TypeApplication: """A parameterized type, such as `Material[Plate]`.""" @@ -83,6 +109,28 @@ def __repr__(self) -> str: return f"" +class InState: + """A type narrowed to one facet state, such as `inoculated(Medium)`. + + Lab writes this `Medium is inoculated`, which is not something Python can + parse. Calling the state reads the same way round and is an ordinary + callable: the state is what you know, and the subject is what you know it + about. + """ + + __slots__ = ("state", "subject") + + def __init__(self, subject: object, state: str) -> None: + self.subject = subject + self.state = state + + def render(self) -> str: + return f"{lab_type(self.subject)} is {self.state}" + + def __repr__(self) -> str: + return f"" + + def lab_type(annotation: object) -> str: """The Lab type an annotation states.""" @@ -90,11 +138,13 @@ def lab_type(annotation: object) -> str: return "None" if isinstance(annotation, str): return _from_text(annotation) - if isinstance(annotation, TypeApplication): + if isinstance(annotation, (TypeApplication, InState)): return annotation.render() origin = typing.get_origin(annotation) if origin is not None: arguments = typing.get_args(annotation) + if (state := state_name(origin)) is not None: + return f"{lab_type(arguments[0])} is {state}" if origin in (types.UnionType, typing.Union): return " | ".join(lab_type(argument) for argument in arguments) rendered = ", ".join(lab_type(argument) for argument in arguments) @@ -128,6 +178,9 @@ def _name_of(annotation: object) -> str | None: def type_modules(annotation: object) -> Iterator[str]: """The Lab modules the names in an annotation come from.""" + if isinstance(annotation, InState): + yield from type_modules(annotation.subject) + return if isinstance(annotation, TypeApplication): yield from type_modules(annotation.constructor) for argument in annotation.arguments: @@ -135,6 +188,8 @@ def type_modules(annotation: object) -> Iterator[str]: return origin = typing.get_origin(annotation) if origin is not None: + if state_name(origin) is not None: + yield from getattr(origin, "__lab_uses__", ()) for argument in typing.get_args(annotation): yield from type_modules(argument) return diff --git a/crates/lab-python/python/lab/_vocabulary.py b/crates/lab-python/python/lab/_vocabulary.py index a82cbdba..e6923ba8 100644 --- a/crates/lab-python/python/lab/_vocabulary.py +++ b/crates/lab-python/python/lab/_vocabulary.py @@ -22,11 +22,40 @@ ) from ._expressions import Expression from ._source import caller_origin -from ._types import TypeApplication +from ._types import InState, TypeApplication _ArtifactKindT = TypeVar("_ArtifactKindT", bound="ArtifactKind") +class State(Expression): + """One state a facet admits, such as `competent` or `inoculated`. + + A state is a value where a declaration states it and a narrowing where a + type is written: `inoculated[Medium]` is `Medium is inoculated`. Lab spells + the narrowing with `is`, which Python reads as identity, and an annotation + may not be a call, so subscripting is what is left and it reads the way the + words do. + """ + + __slots__ = ("name", "uses") + + def __init__(self, *, name: str, uses: Sequence[str] = ()) -> None: + self.name = name + self.uses = tuple(uses) + + def render(self) -> str: + return self.name + + def lab_modules(self) -> Iterator[str]: + yield from self.uses + + def __getitem__(self, subject: object) -> InState: + return InState(subject, self.name) + + def __repr__(self) -> str: + return f"" + + class Symbol(Expression): """A name a Lab module exports.""" diff --git a/crates/lab-python/python/lab/_workflows.py b/crates/lab-python/python/lab/_workflows.py index 1ac329b7..58b9fcb9 100644 --- a/crates/lab-python/python/lab/_workflows.py +++ b/crates/lab-python/python/lab/_workflows.py @@ -130,6 +130,11 @@ def _translate(fn: Callable[..., Any], module: Module, origin: Origin) -> Workfl results = _results(fn, tree) body = _Body(fn, module, {name for name, _ in parameters}) lines = body.block(tree.body, skip_doc=True) + # A signature names types the body may never mention: a workflow that only + # performs other workflows still returns their materials. The modules its + # annotations come from import after the ones the body needed. + for hint in _annotations(fn).values(): + body.uses.update(dict.fromkeys(type_modules(hint))) declaration = WorkflowDeclaration( module=module, name=fn.__name__, @@ -233,7 +238,10 @@ def __init__(self, fn: Callable[..., Any], module: Module, bound: set[str]) -> N self.context = _context_name(fn) self.bound = set(bound) self.state: set[str] = set() - self.uses: set[str] = set() + # Keyed by module path in the order the body first needs each one. The + # emitted `use` lines read from this, and emitted source must be + # byte-stable across runs, so iteration order cannot come from a set. + self.uses: dict[str, None] = {} def block(self, statements: Sequence[ast.stmt], *, skip_doc: bool = False) -> list[str]: """The statements of one body, spaced the way Lab is written by hand. @@ -339,7 +347,7 @@ def declare_state(self, target: ast.expr, value: ast.expr, writer: SourceWriter) f"as wf.state(list[Observation], []), at {self._where(value)}" ) stated = lab_type(self.evaluate(arguments[0])) - self.uses.update(type_modules(self.evaluate(arguments[0]))) + self.uses.update(dict.fromkeys(type_modules(self.evaluate(arguments[0])))) writer.line(f"state {name}: {stated} = {self.expression(arguments[1]).render()}") self.bound.add(name) self.state.add(name) @@ -354,7 +362,7 @@ def perform(self, target: ast.expr | None, value: ast.expr, writer: SourceWriter f"wf.perform takes a durable action or another workflow, not " f"{type(step).__name__}, at {self._where(value)}" ) - self.uses.update(step.lab_modules()) + self.uses.update(dict.fromkeys(step.lab_modules())) names = [] if target is None else _targets(target, self._where(value)) expected = len(step.results) if names and len(names) != expected: @@ -520,7 +528,7 @@ def expression(self, node: ast.expr) -> Expression: rendered = expression(value) except TypeError as error: raise WorkflowError(f"{ast.unparse(node)} is not a Lab expression: {error}") from error - self.uses.update(rendered.lab_modules()) + self.uses.update(dict.fromkeys(rendered.lab_modules())) return rendered def evaluate(self, node: ast.expr) -> Any: diff --git a/crates/lab-python/python/lab/bio/designs.py b/crates/lab-python/python/lab/bio/designs.py index 1e76c16f..f56a7ec1 100644 --- a/crates/lab-python/python/lab/bio/designs.py +++ b/crates/lab-python/python/lab/bio/designs.py @@ -14,8 +14,8 @@ from typing import Generic, TypeVar -from .._types import LabType -from .._vocabulary import ArtifactKind +from .._types import LabConstructor, LabState, LabType +from .._vocabulary import ArtifactKind, Symbol _T1 = TypeVar("_T1") _T2 = TypeVar("_T2") @@ -35,6 +35,74 @@ class Both(LabType, Generic[_T1, _T2]): __lab_uses__ = ("std.bio.designs",) +Competence = Symbol(name="Competence", uses=("std.bio.designs",)) +"""Whether a chassis will take up DNA. + +Cells are made competent or bought that way, and the difference matters to +the one operation that needs it: transformation takes cells that are, and +says so, rather than trusting that whatever was fetched will do. + +How competent they are is the batch's own number. A preparation is accepted +on a control transformation, so the efficiency belongs to the cells rather +than to the strain they came from or the plasmid they will carry. +""" + + +class naive(LabState, Generic[_T1]): + __lab_state__ = "naive" + __lab_uses__ = ("std.bio.designs",) + + +class competent(LabState, Generic[_T1]): + __lab_state__ = "competent" + __lab_uses__ = ("std.bio.designs",) + + +Cultivation = Symbol(name="Cultivation", uses=("std.bio.designs",)) +"""Where an organism is in the course of being grown. + +A culture was a type of its own, which is why it could never say what was +growing in it. It is an organism in a state, so the organism is the type and +how far along it is travels beside it. +""" + + +class designed(LabState, Generic[_T1]): + __lab_state__ = "designed" + __lab_uses__ = ("std.bio.designs",) + + +class transformed(LabState, Generic[_T1]): + __lab_state__ = "transformed" + __lab_uses__ = ("std.bio.designs",) + + +class recovered(LabState, Generic[_T1]): + __lab_state__ = "recovered" + __lab_uses__ = ("std.bio.designs",) + + +class diluted(LabState, Generic[_T1]): + __lab_state__ = "diluted" + __lab_uses__ = ("std.bio.designs",) + + +class isolated(LabState, Generic[_T1]): + __lab_state__ = "isolated" + __lab_uses__ = ("std.bio.designs",) + + +class grown(LabState, Generic[_T1]): + __lab_state__ = "grown" + __lab_uses__ = ("std.bio.designs",) + + +class Ingredient(LabConstructor): + """How much of one substance a medium holds per unit volume.""" + + __lab_uses__ = ("std.bio.designs",) + + class Operon(LabType, Generic[_T1, _T2]): """Two products expressed from one promoter. @@ -46,6 +114,29 @@ class Operon(LabType, Generic[_T1, _T2]): __lab_uses__ = ("std.bio.designs",) +Pouring = Symbol(name="Pouring", uses=("std.bio.designs",)) +"""Whether a medium has been poured and what has been put on it. + +A plate was a type of its own and could not say what it was poured from, so +plating on the wrong medium was not something the compiler could see. +""" + + +class prepared(LabState, Generic[_T1]): + __lab_state__ = "prepared" + __lab_uses__ = ("std.bio.designs",) + + +class poured(LabState, Generic[_T1]): + __lab_state__ = "poured" + __lab_uses__ = ("std.bio.designs",) + + +class inoculated(LabState, Generic[_T1]): + __lab_state__ = "inoculated" + __lab_uses__ = ("std.bio.designs",) + + class Antibiotic(ArtifactKind, LabType): """A selection agent a transformed culture is plated on.""" @@ -101,6 +192,26 @@ class Chassis(ArtifactKind, LabType): ) +class Medium(ArtifactKind, LabType): + """What an organism is grown in or on. + + A medium is a recipe: what goes in it, and how much of each per unit volume. + Concentrations rather than masses, because a recipe is the same whether a + laboratory makes half a litre or five, and what to weigh out is the recipe + times the batch. + + A solid medium is a liquid one with a gelling agent, which is why agar is a + component rather than a second kind. + + Properties: components?: List, ph?: Decimal, selection?: Antibiotic. + """ + + word = "medium" + uses = ("std.bio.designs",) + __lab_uses__ = ("std.bio.designs",) + properties = ("components", "ph", "selection") + + class Part(ArtifactKind, LabType): """A part a supplier lists, ordered rather than built. diff --git a/crates/lab-python/python/lab/codegen.py b/crates/lab-python/python/lab/codegen.py index 1f6b0573..6bd08c04 100644 --- a/crates/lab-python/python/lab/codegen.py +++ b/crates/lab-python/python/lab/codegen.py @@ -193,12 +193,16 @@ def _joined(blocks: list[str]) -> str: A class stands two blank lines from its neighbours and a binding one, so the generated mirror is already formatted and regenerating it never shows - up as a diff. + up as a diff. A facet is one block holding both, so what it ends with + decides the space after it. """ + def holds_class(block: str) -> bool: + return block.startswith("class ") or "\nclass " in block + pieces = [blocks[0]] for index, block in enumerate(blocks[1:]): - apart = block.startswith("class ") or blocks[index].startswith("class ") + apart = holds_class(block) or holds_class(blocks[index]) pieces.append("\n\n\n" if apart else "\n\n") pieces.append(block) return "".join(pieces).rstrip() + "\n" @@ -213,11 +217,18 @@ def _runtime_imports(path: str, exports: list[dict[str, Any]], constructors: fro types.add("LabType") if "function" in kinds: names.add("Function") - if kinds & {"value", "constructor"}: + # A facet generates its own name and one per state it admits, and every + # one of them is a bare word Lab reads back. + if kinds & {"value", "constructor", "facet"}: names.add("Symbol") + if "facet" in kinds: + types.add("LabState") if "type" in kinds: types.add("LabType") - if constructors & {export["name"] for export in exports if export["kind"] == "type"}: + if any( + export["kind"] == "type" and (export["name"] in constructors or export.get("fields")) + for export in exports + ): types.add("LabConstructor") if "role" in kinds: types.add("LabRole") @@ -247,8 +258,13 @@ def _indented(block: str) -> list[str]: def _parameters(exports: list[dict[str, Any]]) -> int: """The most type parameters any one type in a module takes.""" + # A facet's states are generic in the one type each narrows. return max( - (export.get("parameters") or 0 for export in exports if export["kind"] == "type"), + ( + 1 if export["kind"] == "facet" else (export.get("parameters") or 0) + for export in exports + if export["kind"] in ("type", "facet") + ), default=0, ) @@ -273,6 +289,8 @@ def _export( return _action(export, uses) if export["kind"] in ("type", "role"): return _lab_type(export, uses, constructors) + if export["kind"] == "facet": + return _facet(export, uses) factory = "Function" if export["kind"] == "function" else "Symbol" name = export["name"] assignment = f'{name} = {factory}(name="{name}", uses={_tuple(uses)})' @@ -283,6 +301,45 @@ def _export( return _documented(assignment, export) +def _facet(export: dict[str, Any], uses: tuple[str, ...]) -> str: + """A facet, generated as its name and one name per state it admits. + + The name is what a declaration states and each state is what it states as + the value, so `competence = competent` needs both to be importable. A state + is a bare word in Lab, which is what a `Symbol` renders as. + """ + + blocks = [_documented(_symbol(export["name"], uses), export)] + blocks.extend(_state(state, uses) for state in export.get("states") or ()) + return "\n\n\n".join(blocks) + + +def _state(name: str, uses: tuple[str, ...]) -> str: + """One state, generated as a generic class so an annotation may name it. + + `inoculated[Medium]` reads to a type checker the way `Medium is inoculated` + reads to the compiler, and a bare `inoculated` still states the state where + a declaration puts itself in one. + """ + + return "\n".join( + [ + f"class {name}(LabState, Generic[_T1]):", + f' __lab_state__ = "{name}"', + f" __lab_uses__ = {_tuple(uses)}", + ] + ) + + +def _symbol(name: str, uses: tuple[str, ...]) -> str: + assignment = f'{name} = Symbol(name="{name}", uses={_tuple(uses)})' + if len(assignment) > _LIMIT: + assignment = "\n".join( + [f"{name} = Symbol(", f' name="{name}",', f" uses={_tuple(uses)},", ")"] + ) + return assignment + + def _lab_type( export: dict[str, Any], uses: tuple[str, ...], constructors: frozenset[str] = frozenset() ) -> str: @@ -297,7 +354,10 @@ def _lab_type( parameters = export.get("parameters") or 0 if export["kind"] == "role": base = "LabRole" - elif export["name"] in constructors: + # A record with fields is a thing you build, and Lab writes building one the + # same way it writes naming one. The mirror is a single class for the same + # reason: it annotates like a type and calling it builds the record. + elif export["name"] in constructors or export.get("fields"): base = "LabConstructor" else: base = "LabType" diff --git a/crates/lab-python/python/lab/competence.py b/crates/lab-python/python/lab/competence.py new file mode 100644 index 00000000..c2c7bb8a --- /dev/null +++ b/crates/lab-python/python/lab/competence.py @@ -0,0 +1,132 @@ +"""Making a chassis competent. + +Competent cells are grown up, chilled, spun into a pellet, and washed into a +cold buffer until they will take up DNA. None of these steps is assembly or +transformation, so none had a verb until a package could declare one. Each +verb here is an `action`: it names the material it takes, the material it +yields and the state that yielding leaves it in, and the capability a bench +or a robot must offer to run it. The compiler derives a manual method for +each, so the sequence plans without a line of Rust. + +A buffer and a medium are both solutions a laboratory pours, and both are +washed or grown into cells, so they share the `Solution` role rather than +repeating what a solution can do on each kind. +""" + +# Generated from the Lab standard library by `python -m lab.codegen`. Do not edit. + +from typing import Generic, TypeVar + +from ._effects import Action +from ._types import LabState, LabType +from ._vocabulary import ArtifactKind, Symbol + +_T1 = TypeVar("_T1") + +LAB_MODULE = "std.lab.competence" +"""The Lab module these names come from.""" + + +Growth = Symbol(name="Growth", uses=("std.bio.designs", "std.lab.competence")) +"""How far a batch of cells is along the way to being competent. + +Competence itself is a separate fact, declared where a chassis is: cells are +competent or they are not, and transformation is the one operation that +cares. These are the physical states a preparation passes through before it +gets there, so a verb that spins cells down can say it takes ones that are +growing and leaves ones that are pelleted. +""" + + +class dormant(LabState, Generic[_T1]): + __lab_state__ = "dormant" + __lab_uses__ = ("std.bio.designs", "std.lab.competence") + + +class growing(LabState, Generic[_T1]): + __lab_state__ = "growing" + __lab_uses__ = ("std.bio.designs", "std.lab.competence") + + +class pelleted(LabState, Generic[_T1]): + __lab_state__ = "pelleted" + __lab_uses__ = ("std.bio.designs", "std.lab.competence") + + +class Buffer(ArtifactKind, LabType): + """A salt solution cells are washed and resuspended in. + + A buffer and a medium are both solutions a laboratory pours, so both play the + `Solution` role and a verb that resuspends cells asks for either. The + concentration is the batch's own: a competent-cell protocol is written for a + molarity of calcium chloride, and what to weigh out is that times the volume. + + Properties: concentration?: Quantity. + """ + + word = "buffer" + uses = ("std.bio.designs", "std.lab.competence") + __lab_uses__ = ("std.bio.designs", "std.lab.competence") + properties = ("concentration",) + + +centrifuge = Action( + name="centrifuge", + phrase=("centrifuge", "", "at", "", "for", ""), + results=("pellet",), + uses=("std.bio.designs", "std.lab.competence"), +) +"""Spin a chilled culture into a pellet at a stated relative force. + +Performed as `centrifuge at for `. + +Binds pellet. +""" + +chill = Action( + name="chill", + phrase=("chill", "", "for", ""), + results=("chilled",), + uses=("std.bio.designs", "std.lab.competence"), +) +"""Chill a growing culture on ice before it is spun down. + +Performed as `chill for `. + +Binds chilled. +""" + +grow = Action( + name="grow", + phrase=("grow", "", "at", "", "to", ""), + results=("culture",), + uses=("std.bio.designs", "std.lab.competence"), +) +"""Grow cells up to a target optical density. + +The target is read at 600 or 700 nanometres, and the two do not convert: an +OD600 of 0.4 is not an OD700 of 0.4, so the unit says which meter the number +came off. Either reaches the same growing culture, so the operand admits +either and the protocol writes whichever its plate reader reports. + +Performed as `grow at to `. + +Binds culture. +""" + +resuspend = Action( + name="resuspend", + phrase=("resuspend", "", "in", ""), + results=("competent",), + uses=("std.bio.designs", "std.lab.competence"), +) +"""Resuspend a pellet in cold buffer, which is the wash that makes it competent. + +The buffer is a solution, so the same verb pours a calcium-chloride wash or +any other a protocol calls for. What comes out is competent: ready for the +one operation that takes cells that are. + +Performed as `resuspend in `. + +Binds competent. +""" diff --git a/crates/lab-python/python/lab/plasmid.py b/crates/lab-python/python/lab/plasmid.py index 803904ae..b91f5fb5 100644 --- a/crates/lab-python/python/lab/plasmid.py +++ b/crates/lab-python/python/lab/plasmid.py @@ -86,11 +86,11 @@ plate = Action( name="plate", - phrase=("plate", "", "on", ""), + phrase=("plate", "", "on", ""), results=("plate",), uses=("std.lab.plasmid",), ) -"""Performed as `plate on `. +"""Performed as `plate on `. Binds plate. """ @@ -117,13 +117,13 @@ Binds screening. """ -grow = Action( - name="grow", - phrase=("grow", "", "at", "", "for", ""), +culture = Action( + name="culture", + phrase=("culture", "", "at", "", "for", ""), results=("culture",), uses=("std.lab.plasmid",), ) -"""Performed as `grow at for `. +"""Performed as `culture at for `. Binds culture. """ diff --git a/crates/lab-python/tests/programs/competence.py b/crates/lab-python/tests/programs/competence.py new file mode 100644 index 00000000..9f32f302 --- /dev/null +++ b/crates/lab-python/tests/programs/competence.py @@ -0,0 +1,35 @@ +"""Making competent cells, written in Python. + +The same protocol the compiler's own tests build in Lab, expressed through the +SDK: growing cells to a target optical density, chilling them, spinning them +into a pellet, and washing them into cold buffer. Every verb comes from +`std.lab.competence` as a declared action, so the SDK mirrors it without any +Python written by hand for it. +""" + +import lab +from lab import Material +from lab.bio.designs import Chassis, competent +from lab.competence import Buffer, centrifuge, chill, grow, resuspend +from lab.units import OD600, C, minutes, mM, rcf + +module = lab.Module("competence.protocol", doc=__doc__) + +cold_cacl2 = Buffer.buy(identity="SIGMA-C1016", concentration=100 * mM) + +DH5a_competent = Chassis.build( + doc="A cloning strain grown up and washed into competence.", + heat_shock_temperature=42 * C, +) + + +@lab.workflow +def prepare(wf: lab.Context) -> Material[competent[Chassis]]: + """Grow, chill, pellet, and wash a chassis into competence.""" + cells = wf.perform(lab.realize(DH5a_competent)) + wash = wf.perform(lab.provision(cold_cacl2)) + culture = wf.perform(grow(cells, temperature=37 * C, target=0.40 * OD600)) + chilled = wf.perform(chill(culture, duration=10 * minutes)) + pellet = wf.perform(centrifuge(chilled, force=4000 * rcf, duration=10 * minutes)) + ready = wf.perform(resuspend(pellet, buffer=wash)) + return ready diff --git a/crates/lab-python/tests/programs/golden_gate/inventory.py b/crates/lab-python/tests/programs/golden_gate/inventory.py index fa78cba1..245d4d59 100644 --- a/crates/lab-python/tests/programs/golden_gate/inventory.py +++ b/crates/lab-python/tests/programs/golden_gate/inventory.py @@ -7,8 +7,20 @@ import lab from lab import dna -from lab.bio.designs import CDS, Antibiotic, Backbone, Chassis, Part, Promoter, RestrictionEnzyme -from lab.units import C, minutes +from lab.bio.designs import ( + CDS, + Antibiotic, + Backbone, + Chassis, + Ingredient, + Medium, + Part, + Promoter, + RestrictionEnzyme, + competent, + poured, +) +from lab.units import C, L, cfu, g, minutes, ug module = lab.Module("golden_gate.designs.inventory", doc=__doc__) @@ -119,6 +131,8 @@ # 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( + competence=competent, + efficiency=10**9 * cfu / ug, sbol_identity="https://sbolcanvas.org/DH5alpha", heat_shock_temperature=42 * C, cold_incubation=30 * minutes, @@ -127,6 +141,8 @@ ) BL21 = Chassis.buy( + competence=competent, + efficiency=10**7 * cfu / ug, sbol_identity="https://sbolcanvas.org/BL21", heat_shock_temperature=42 * C, cold_incubation=30 * minutes, @@ -137,3 +153,14 @@ chloramphenicol = Antibiotic.buy( sbol_identity="https://example.org/golden-gate/materials/chloramphenicol" ) +LB_chloramphenicol_agar = Medium.buy( + sbol_identity="https://example.org/golden-gate/materials/LB_chloramphenicol_agar", + pouring=poured, + selection=chloramphenicol, + components=[ + Ingredient(substance="tryptone", concentration=10 * g / L), + Ingredient(substance="yeast extract", concentration=5 * g / L), + Ingredient(substance="sodium chloride", concentration=10 * g / L), + Ingredient(substance="agar", concentration=15 * g / L), + ], +) diff --git a/crates/lab-python/tests/programs/reporter/observe.py b/crates/lab-python/tests/programs/reporter/observe.py index 6533fe12..0d353c9e 100644 --- a/crates/lab-python/tests/programs/reporter/observe.py +++ b/crates/lab-python/tests/programs/reporter/observe.py @@ -9,9 +9,9 @@ Evidential, Image, Material, - Plate, detect_colonies, ) +from lab.bio.designs import Medium, inoculated from lab.units import h, minutes module = lab.Module("reporter.observe", doc=__doc__) @@ -30,7 +30,7 @@ class PlateObservation(Evidential): class ColonyGrowth: """What watching a plate produced.""" - plate: Material[Plate] + plate: Material[inoculated[Medium]] observations: list[PlateObservation] @lab.case @@ -43,7 +43,7 @@ class TimedOut: @lab.workflow -def grow_colonies(wf: lab.Context, plate: Material[Plate]) -> ColonyGrowth: +def grow_colonies(wf: lab.Context, plate: Material[inoculated[Medium]]) -> ColonyGrowth: """Image every half hour, and stop at the first plate worth picking from.""" observations = wf.state(list[PlateObservation], []) diff --git a/crates/lab-python/tests/programs/reporter/workflow.py b/crates/lab-python/tests/programs/reporter/workflow.py index 9c261a7e..d16d4922 100644 --- a/crates/lab-python/tests/programs/reporter/workflow.py +++ b/crates/lab-python/tests/programs/reporter/workflow.py @@ -1,20 +1,27 @@ """Assemble the reporter, transform it, and plate what recovers.""" import lab -from lab import Material, Plate -from lab.bio.designs import Antibiotic, Chassis, Strain -from lab.units import C, h, minutes +from lab import Material +from lab.bio.designs import Antibiotic, Chassis, Medium, Strain, competent, inoculated, poured +from lab.units import C, cfu, h, minutes, ug from .plasmid import reporter module = lab.Module("reporter.workflow", doc=__doc__) DH5alpha = Chassis.buy( + competence=competent, + efficiency=10**9 * cfu / ug, identity="ATCC-53868", heat_shock_temperature=42 * C, recovery_duration=60 * minutes, ) chloramphenicol = Antibiotic.buy(identity="SIGMA-C0378") +LB_chloramphenicol_agar = Medium.buy( + identity="LB-CAM-AGAR", + pouring=poured, + selection=chloramphenicol, +) reporter_host = Strain.build( doc="The reporter carried in a cloning strain.", @@ -25,12 +32,13 @@ @lab.workflow -def build_reporter(wf: lab.Context) -> tuple[Material[Strain], Material[Plate]]: +def build_reporter(wf: lab.Context) -> tuple[Material[Strain], Material[inoculated[Medium]]]: """Assemble the reporter, transform it, and plate what recovers.""" product = wf.perform(lab.realize(reporter)) cells = wf.perform(lab.provision(DH5alpha)) strain, culture = wf.perform(lab.transform(reporter_host, plasmids=[product], cells=cells)) culture = wf.perform(lab.recover(culture, duration=1 * h)) culture = wf.perform(lab.dilute(culture)) - plate = wf.perform(lab.plate(culture, antibiotic=chloramphenicol)) + agar = wf.perform(lab.provision(LB_chloramphenicol_agar)) + plate = wf.perform(lab.plate(culture, medium=agar)) return strain, plate diff --git a/crates/lab-python/tests/test_competence.py b/crates/lab-python/tests/test_competence.py new file mode 100644 index 00000000..ad63b707 --- /dev/null +++ b/crates/lab-python/tests/test_competence.py @@ -0,0 +1,36 @@ +"""The competent-cell protocol checks and refines through the shared compiler. + +The program lives in `programs.competence`, written in the object model. This +reads the Lab it emits and drives it through refinement, the same protocol the +compiler's own tests build in Lab. +""" + +import unittest + +import lab +from programs import competence + + +class CompetenceProtocolTests(unittest.TestCase): + def setUp(self) -> None: + self.source = competence.module.source() + + def test_the_declared_verbs_emit_their_phrases(self) -> None: + self.assertIn("culture <- grow cells at 37 C to 0.4 OD600", self.source) + self.assertIn("chilled <- chill culture for 10 min", self.source) + self.assertIn("pellet <- centrifuge chilled at 4000 rcf for 10 min", self.source) + self.assertIn("ready <- resuspend pellet in wash", self.source) + + def test_the_protocol_checks_and_refines(self) -> None: + program = lab.check(competence.module) + self.assertIn("competence.protocol", program.checked) + refined = lab.refine(program) + operations = {choice["source_operation"] for choice in refined.planning_problem["choices"]} + self.assertIn("std.lab.competence.grow", operations) + self.assertIn("std.lab.competence.chill", operations) + self.assertIn("std.lab.competence.centrifuge", operations) + self.assertIn("std.lab.competence.resuspend", operations) + + +if __name__ == "__main__": + unittest.main() diff --git a/crates/lab-python/tests/test_workflows.py b/crates/lab-python/tests/test_workflows.py index ce98be2e..9674638a 100644 --- a/crates/lab-python/tests/test_workflows.py +++ b/crates/lab-python/tests/test_workflows.py @@ -22,6 +22,7 @@ * Watch a plate until it has something worth picking from. */ +use std.bio.designs use std.lab.plasmid /** One image, what was counted in it, and how long the plate had grown. */ @@ -32,7 +33,7 @@ /** What watching a plate produced. */ record ColonyGrowth: - plate: Material + plate: Material observations: List case Ready: @@ -42,7 +43,7 @@ /** Image every half hour, and stop at the first plate worth picking from. */ workflow grow_colonies( - plate: Material, + plate: Material, ) -> ColonyGrowth: state observations: List = [] @@ -182,7 +183,7 @@ def test_one_workflow_performs_another(self) -> None: def test_several_results_are_named_after_what_the_body_returns(self) -> None: self.assertIn("strain: Material,", self.build) - self.assertIn("plate: Material,", self.build) + self.assertIn("plate: Material,", self.build) def test_one_result_needs_no_name(self) -> None: self.assertIn("-> ColonyGrowth:", self.observe) @@ -207,7 +208,8 @@ def _translate(self, body: str, **rest: str) -> None: namespace: dict[str, Any] = {} source = ( "import lab\n" - "from lab import Material, Plate, Strain\n" + "from lab import Material, Strain\n" + "from lab.bio.designs import Medium, inoculated\n" 'module = lab.Module("refusal.demo")\n' "@lab.workflow\n" + body ) diff --git a/docs/README.md b/docs/README.md index 0da236b6..614aee77 100644 --- a/docs/README.md +++ b/docs/README.md @@ -76,6 +76,9 @@ Decision records preserve the reasoning and status behind the language rather th | [0049: Pipetting techniques cross device boundaries](language/decisions/0049-pipetting-techniques-cross-device.md) | portable liquid-access constraints remain distinct from calibrated adapter realizations | | [0050: Allocated Procedure schedules make device batching explicit](language/decisions/0050-allocated-procedure-schedules.md) | fusing complete tasks into one device run is a checked artifact that preserves every identity | | [0051: Interchangeable physical resources resolve without a pin](language/decisions/0051-interchangeable-resources-resolve-without-a-pin.md) | equally usable MaterialLots bind deterministically and reviewably; Assets stay an explicit choice | +| [0052: Material states are declared facets, not separate kinds](language/decisions/0052-material-states-are-declared-facets.md) | a state travels on the material as an orthogonal declared facet, keeping provenance and kind distinct | +| [0053: Quantities carry dimensions and compose](language/decisions/0053-quantities-carry-dimensions-and-compose.md) | a field may ask for a dimension, conversion is written, and products and quotients are computed | +| [0054: Every material carries a quantity](language/decisions/0054-every-material-carries-a-quantity.md) | a divisible material is drawn from as one take and two introductions, needing no exception to affine flow | ## Implementation and embedding diff --git a/docs/language/README.md b/docs/language/README.md index b8631a4a..0089fe02 100644 --- a/docs/language/README.md +++ b/docs/language/README.md @@ -95,3 +95,6 @@ The latest accepted design records are: - [`0049`](decisions/0049-pipetting-techniques-cross-device.md): pipetting technique is a portable constraint whose numeric realization belongs to the adapter profile. - [`0050`](decisions/0050-allocated-procedure-schedules.md): fusing complete allocated tasks into one device run is a checked schedule that preserves task, requirement, and material identity. - [`0051`](decisions/0051-interchangeable-resources-resolve-without-a-pin.md): interchangeable MaterialLots resolve deterministically and reviewably, while choosing between Assets stays explicit. +- [`0052`](decisions/0052-material-states-are-declared-facets.md): a material's state is a declared orthogonal facet of its design kind, not a separate type. +- [`0053`](decisions/0053-quantities-carry-dimensions-and-compose.md): quantities carry dimensions, convert only where written, and compose under multiplication and division. +- [`0054`](decisions/0054-every-material-carries-a-quantity.md): every material carries a quantity, and a divisible one is drawn from within ordinary affine ownership. diff --git a/docs/language/decisions/0052-material-states-are-declared-facets.md b/docs/language/decisions/0052-material-states-are-declared-facets.md new file mode 100644 index 00000000..bea84e91 --- /dev/null +++ b/docs/language/decisions/0052-material-states-are-declared-facets.md @@ -0,0 +1,111 @@ +# 0052: Material states are declared facets, not separate kinds + +## Status + +Accepted. Extends [0006: Affine material flow in portable workflows](0006-affine-material-flow.md) +and is governed by the vocabulary rule in +[0022: Fixed grammar, open vocabulary](0022-fixed-grammar-open-vocabulary.md). + +## Context + +`Culture`, `Clone`, and `Plate` are nominal prelude types with no fields. A culture cannot say which +organism is growing or which medium it grows in. A plate cannot say the medium it was poured from. +They are opaque because they are not kinds of thing: they are states some design is in, and there is +no design underneath them to ask. + +LAIR already models this correctly. `MaterialType` is a set of material states carrying +`material-state#` identities, and the verifier enforces them: `TransformOp` refuses a `cells` operand +that is not `CompetentCells`. Transformation into cells nobody made competent is already a checked +error one layer below the language. + +Because the source language has no state, the state is assumed at the lowering site rather than +derived. Every `provision` constructs `ProvisionOp::competent_cells`, so `provision chloramphenicol` +checks as `Material` at the frontend and arrives in LAIR as a value the IR believes is +competent cells. The frontend type and the IR state disagree and nothing reconciles them. + +Encoding a state as a new kind does not fix this; it is the same mistake again. It would also make +provenance a type distinction, which [0027](0027-provenance-is-stated-per-thing.md) refuses: bought +competent cells and competent cells made from an overnight culture are the same thing in the same +state, differing only in where they came from. + +## Decision + +A material is `Material` for a design kind `T`. What state it is in travels on the material. + +A **facet** is a named classification of a kind's materials, declared at module scope: + +```lab +/** Whether a chassis has been made competent, and how well. */ +facet Competence on Chassis: + naive + competent: + efficiency: Quantity + + naive -> competent +``` + +A facet lists its states in order, and the first is the state a newly established material is in +unless its declaration says otherwise. A state may carry fields, which is what `Culture` needed and +could not have. Transitions are written explicitly, and an action that establishes a state the facet +does not reach from the state it required is a diagnostic. + +**Facets are orthogonal.** A kind may carry several, and a culture that is both diluted and grown +under selection is two facets rather than one flattened state. Flattening is what produced +`DilutedCulture` and `RecoveredCulture` in the IR, and it does not survive a third axis. + +Facets are contributed to, exactly as schemas are under +[0028](0028-schemas-are-contributed-to.md). A package may declare a facet on a kind another package +declared, so a laboratory can track an axis the standard library does not. + +A material type constrains a facet with `is`, the word that already means *plays this +classification*: + +```lab +transform GVD_strain from dependencies into (cells: Material) +``` + +An action contract states the facet state it requires of each operand and the state it establishes on +each result. That table already drives ownership checking under 0006; state checking joins it rather +than becoming a second pass. + +Provenance stays orthogonal. `buy chassis DH5alpha: competence = competent` and a workflow that makes +its own competent cells produce the same type in the same state, and +[0027](0027-provenance-is-stated-per-thing.md) continues to say which is which. + +Transitions are declared rather than inferred. Inference could be added later over a declared graph; +deriving the graph from usage first would mean the compiler agrees with whatever the protocol +happened to do. + +## Consequences + +- `Culture`, `Clone`, and `Plate` stop being types. A culture is `Material`, a + picked colony is `Material`, and a plate is `Material`. + Each now names the design underneath it, which is what none of them could do. +- Plating states the medium it spreads on rather than an antibiotic beside it, so what a plate + selects for is read from what it is made of. +- A state's declared fields are required wherever the state is stated. Cells are not competent in the + abstract; they are competent to a number, and that number is what a batch is accepted on. +- An operand may be declared to carry no lineage. A plate is a culture on agar: the culture is the + organism and the agar is what it sits on, and counting the agar would make one plate look like two + independent things. +- Provisioning stops minting competent cells for everything. The state of a provisioned material is + named for the kind that was fetched, so an antibiotic no longer arrives as a value the IR believes + is a tube of cells. + + This needed a second change, because a Method's ports name their state literally and the registry + requires every candidate refining one Intent to share a signature. There was therefore exactly one + provisioning signature and it said `CompetentCells`. Fetching a chassis and fetching a plasmid land + in different states, so neither a second method nor one uniform state could express it. A port may + now say that its state is the one its Intent asked for, resolved from the Intent result it is + exported as. Only an output may say it, and only where a Method output exports it, since there is + otherwise no result to read the state from; both are refused when a definition is validated. +- `MaterialType` carries an absolute IRI rather than one of a closed set, so it gains states without + a change to its definition. The set was already too small: `method::standard` mints + `AssemblyReaction`, `TransformationMixture`, and `RecoveryMixture`, none of which the enumeration + admitted. Refinement carries the state across the dialect boundary unchanged, so the table that + translated seven variants into seven IRIs is gone and cannot fall out of step with the states a + package declares. +- Transforming into cells that were never made competent becomes a frontend diagnostic pointing at + the operand, rather than a verifier failure in LAIR or nothing at all. +- A facet with no transition into a state makes that state unreachable, which is a diagnostic at + declaration rather than a protocol that cannot be planned. diff --git a/docs/language/decisions/0053-quantities-carry-dimensions-and-compose.md b/docs/language/decisions/0053-quantities-carry-dimensions-and-compose.md new file mode 100644 index 00000000..0d319a62 --- /dev/null +++ b/docs/language/decisions/0053-quantities-carry-dimensions-and-compose.md @@ -0,0 +1,70 @@ +# 0053: Quantities carry dimensions and compose + +## Status + +Accepted. Amends [0025: A quantity type names the unit it is measured in](0025-quantity-types.md). + +## Context + +0025 made `Quantity
        ` a written type whose argument is a unit, and kept the unit check exact: +`20 mL` where microlitres are written is a diagnostic rather than a conversion, because a +thousandfold error on the bench is worth refusing. That is right for a field holding one measurement, +and it stays right. + +It cannot express a recipe. LB is 10 g/L tryptone, 5 g/L yeast extract, and 10 g/L sodium chloride, +and a field pinning grams per litre refuses the milligrams per litre the next recipe is written in. + +Worse, a recipe states concentrations while a batch is a volume. Someone multiplies 10 g/L by 500 mL +and weighs out 5 g. That multiplication is done in a head or on a scrap of paper, and getting it +wrong is precisely the class of error the language exists to refuse. A design that states grams +instead is no longer a recipe: it is one batch size, and it stops being portable. + +Below the language the dimension set is narrower still. `procedure/quantity/` carries duration, +length, temperature, and volume, and no mass or concentration at all, so a recipe has nothing to +lower into. `ng/uL` exists as a surface unit string with no counterpart underneath it. + +## Decision + +A quantity has a dimension as well as a unit. Mass, volume, amount, concentration, duration, length, +and temperature are dimensions, along with the products and quotients of them. + +**A field may name a dimension instead of a unit.** `Quantity` accepts any volume unit, +reusing the `any` that already forgets a type argument. Each value still pins its own unit; the field +declines to pin one. + +Mass in a volume and amount in a volume are different things, so a field asking for a concentration +refuses millimolar. Going between them needs a molar mass, which is a fact about the substance rather +than about the recipe, and a field holding either says so with a union. + +**Conversion within a dimension is written.** `500 mL in uL` converts. Implicit conversion stays +refused, which is what 0025 was protecting, and a field that pins a unit still pins it. + +**Quantities compose.** Multiplication and division compute the dimension of the result: + +```lab +10 g/L * 500 mL // 5 g +500 ng / 100 ng/uL // 5 uL +``` + +Addition and subtraction require one dimension and yield the unit of the left operand, converting the +right exactly or refusing. A product or quotient yields the canonical unit of the derived dimension, +which `in` converts from. + +**Exactness is preserved.** Conversion scales an exact decimal. A conversion that cannot be +represented exactly is a diagnostic rather than a rounding, so no quantity silently loses precision +on its way to a balance or a pipette. + +`procedure/quantity/` gains mass and concentration with their QUDT identities, because a recipe that +cannot lower is not a recipe. + +## Consequences + +- A recipe states concentrations once and scales to any batch size, so a medium design is portable + across laboratories that make different volumes of it. +- `draw 500 ng from prep` computes a volume from the prep's concentration, which is arithmetic that + otherwise happens at the bench and is not recorded. +- 0025's refusal of `20 mL` where `Quantity
          ` is written is unchanged. Nothing coerces. +- Dimensional errors are caught rather than propagated: a mass where a volume is required names the + two dimensions instead of naming two unit strings that happen to differ. +- A unit is still not a name a signature can introduce or refer to. A dimension is a classification + the compiler knows, not a type parameter a package declares. diff --git a/docs/language/decisions/0054-every-material-carries-a-quantity.md b/docs/language/decisions/0054-every-material-carries-a-quantity.md new file mode 100644 index 00000000..3bd8b3d0 --- /dev/null +++ b/docs/language/decisions/0054-every-material-carries-a-quantity.md @@ -0,0 +1,76 @@ +# 0054: Every material carries a quantity, and divisible materials are drawn from + +## Status + +Accepted, unimplemented. Extends +[0006: Affine material flow in portable workflows](0006-affine-material-flow.md) and depends on +[0053: Quantities carry dimensions and compose](0053-quantities-carry-dimensions-and-compose.md). + +## Context + +0006 gives every `Material` one owning place, verified with the `copy`, `borrow`, and `take` modes +a resolved action contract supplies. That is the right model and this does not change it. + +What a material does not have is a size. `split` is the only way to divide one: it is typed to +`Plasmid`, it yields exactly two, and it says nothing about how much went each way. A shelf holding +250 mL of LB that three protocols draw 100 mL from cannot be written at all, and neither can the +ordinary observation that a source vessel must hold more than a protocol dispenses from it. + +A divisible resource is not in tension with affine ownership. It is the case affine ownership was +built for: the whole is consumed and the parts are introduced, so no place is ever used twice. The +machinery is already present. `split` has the two-result shape, and `culture <- recover culture for +1 h` already consumes a place and introduces one under the same name. + +The accounting also already exists below the language. Vessels carry `initial_volume_each`, +`working_capacity_each`, and `dead_volume_each`, and serial dilution reasons about medium source and +dead volumes. The quantity is real and is tracked; it is simply invisible where the protocol is +written, and a bulk reagent reaches it as an opaque symbol in a Method definition. + +## Decision + +**Every material carries a quantity.** The kind declares the dimension that quantity is measured in +and whether the material is divisible: + +```lab +artifact Medium is ...: + measured in Volume, divisible +``` + +A divisible material is drawn from, and the draw is an ordinary affine consumption: + +```lab +lb, aliquot <- draw 100 mL from lb +``` + +The draw takes the material and introduces two: the remainder and the aliquot. Rebinding the +remainder under the same name is the existing pattern, so 0006 needs no exception and +`material_flow.rs` needs no new concept. `split` becomes a draw of half, or retires. + +An indivisible material is counted rather than measured, and `draw 0.4 from plate` is refused by the +kind rather than by a special case. A count is a quantity like any other under +[0053](0053-quantities-carry-dimensions-and-compose.md). + +Because quantities compose, a draw may be stated in any dimension the material's quantity converts +to. `draw 500 ng from prep` is a volume computed from the prep's concentration. + +Where a quantity is statically known, over-drawing is a diagnostic naming the material and the +shortfall. Where it is not, it resolves against an inventory lot during planning, and the plan +records the lot it bound as [0051](0051-interchangeable-resources-resolve-without-a-pin.md) requires. +Dead volume is expressible at the source, so a protocol can state that a source must retain more than +it dispenses. + +Loops over collections containing materials stay refused under 0006, so a draw inside a loop remains +inexpressible until a consuming iterator contract exists. + +## Consequences + +- Bulk reagents are written where the protocol is written. A medium stops being a string in a Method + definition resolved out of sight of the person reading the protocol. +- Affine flow is unchanged. A draw is one take and two introductions, and the existing analysis + verifies it without learning a new rule. +- The quantity a workflow needs and the quantity a lot holds become the same question, so inventory + resolution can refuse a lot that is too small rather than discovering it at the bench. +- A medium lot drawn on by two cultures is visible as a shared input, which is what lets lineage + analysis treat a shared batch as the confounder it is rather than as two independent preparations. +- Kinds must state a dimension and divisibility, so an existing kind that states neither is + incomplete and says so at its declaration. diff --git a/docs/language/specimens/dependency-build.lab b/docs/language/specimens/dependency-build.lab index a96feaca..ee6f797e 100644 --- a/docs/language/specimens/dependency-build.lab +++ b/docs/language/specimens/dependency-build.lab @@ -18,8 +18,13 @@ buy: backbone region_receiver restriction_enzyme BsaI restriction_enzyme BsmBI - chassis DH5alpha + chassis DH5alpha: + competence = competent + efficiency = 1e9 cfu/ug antibiotic chloramphenicol + medium LB_chloramphenicol_agar: + pouring = poured + selection = chloramphenicol build plasmid promoter_carrier: sequence = dna("ACGT") @@ -64,12 +69,14 @@ workflow build_reporter_host( reporter_region: Material, ) -> ( strain: Material, - plate: Material, + plate: Material, ): dependencies = [reporter_region] cells <- provision DH5alpha strain, culture <- transform reporter_host from dependencies into cells culture <- recover culture for 1 h culture <- dilute culture - plate <- plate culture on chloramphenicol + agar <- provision LB_chloramphenicol_agar + + plate <- plate culture on agar return strain, plate diff --git a/docs/language/specimens/inventory-plasmid.lab b/docs/language/specimens/inventory-plasmid.lab index 0a696363..963eedf4 100644 --- a/docs/language/specimens/inventory-plasmid.lab +++ b/docs/language/specimens/inventory-plasmid.lab @@ -16,8 +16,13 @@ buy: part B0015 backbone pSB1C3 restriction_enzyme BsaI - chassis DH5alpha + chassis DH5alpha: + competence = competent + efficiency = 1e9 cfu/ug antibiotic chloramphenicol + medium LB_chloramphenicol_agar: + pouring = poured + selection = chloramphenicol reporter_sequence: DNA = dna("ACGTACGT") @@ -47,12 +52,14 @@ workflow build_reporter_host( reporter: Material, ) -> ( strain: Material, - plate: Material, + plate: Material, ): dependencies = [reporter] cells <- provision DH5alpha strain, culture <- transform reporter_host from dependencies into cells culture <- recover culture for 1 h culture <- dilute culture - plate <- plate culture on chloramphenicol + agar <- provision LB_chloramphenicol_agar + + plate <- plate culture on agar return strain, plate diff --git a/docs/language/specimens/plasmid-build.lab b/docs/language/specimens/plasmid-build.lab index 7ebc1b3f..4566c9ce 100644 --- a/docs/language/specimens/plasmid-build.lab +++ b/docs/language/specimens/plasmid-build.lab @@ -4,8 +4,13 @@ use std.bio.golden_gate use std.lab.plasmid buy: - chassis competent_ecoli + chassis competent_ecoli: + competence = competent + efficiency = 1e9 cfu/ug antibiotic kanamycin + medium LB_kanamycin_agar: + pouring = poured + selection = kanamycin restriction_enzyme BsaI backbone p15A_kan cds GFP @@ -32,7 +37,7 @@ record PlateObservation is Evidential: elapsed: Duration record ColonyGrowth: - plate: Material + plate: Material observations: List case Ready: @@ -48,7 +53,7 @@ record SequenceCheck: case Mismatch case Inconclusive -workflow await_colonies(plate: Material) -> ColonyGrowth: +workflow await_colonies(plate: Material) -> ColonyGrowth: state observations: List = [] @@ -85,7 +90,9 @@ workflow build_plasmid() -> Accepted | Rejected: strain, culture <- transform reporter_host from plasmids into cells culture <- recover culture for 1 h culture <- dilute culture - plate <- plate culture on kanamycin + agar <- provision LB_kanamycin_agar + + plate <- plate culture on agar <- dispose strain colony_result <- await_colonies plate @@ -105,8 +112,8 @@ workflow build_plasmid() -> Accepted | Rejected: screening <- screen candidates against design clone = screening.clones.highest_confidence - culture <- grow clone at 37 C for 16 h - plasmid <- purify culture + overnight <- culture clone at 37 C for 16 h + plasmid <- purify overnight retained, aliquot <- split plasmid sequence_result <- sequence aliquot diff --git a/docs/language/specimens/plasmid-design.lab b/docs/language/specimens/plasmid-design.lab index b81f0987..6e0c62a8 100644 --- a/docs/language/specimens/plasmid-design.lab +++ b/docs/language/specimens/plasmid-design.lab @@ -21,7 +21,7 @@ plasmid p_tet_reporter: require topology == circular require sites(BsaI) == 0 - require length <= 12 kb + require length <= 12000 bp accept sequence == design.sequence accept concentration >= 100 ng/uL diff --git a/docs/language/syntax.md b/docs/language/syntax.md index b47a67d9..f19493c8 100644 --- a/docs/language/syntax.md +++ b/docs/language/syntax.md @@ -51,14 +51,43 @@ The kernel keeps orchestration mechanics distinct from domain operations: | Role | Words or forms | | --- | --- | | Modules | `use` | -| Declaration shapes | `record`, `circuit`, `artifact`, `workflow`, `state` | -| Classification | `role`, `is`, `any` | +| Declaration shapes | `record`, `circuit`, `artifact`, `facet`, `workflow`, `state` | +| Classification | `role`, `is`, `any`, `on` | | Provenance | `build`, `buy` | | Schemas and contracts | `declares`, `require`, `accept`, `across` | | Control | `if`, `else`, `for`, `in`, `match`, `case`, `return` | | Reactive control | `when`, `every`, `after`, `emit` | | Boolean operators | `and`, `or`, `not` | +A `facet` classifies a kind's materials by the state they are in, which is what keeps a state off the type. Its states and the transitions between them are the vocabulary; `facet` and `on` are the mechanics, and the states a package names are no more in the parser than its artifact words are. See [0052](decisions/0052-material-states-are-declared-facets.md). + +A type argument narrows to a state with the same `is` that says a type plays a role: + +```lab +facet Competence on Chassis: + naive + competent: + efficiency: Quantity + + naive -> competent + +workflow transform_into(cells: Material) -> Material: +``` + +Narrowing runs one way. `Material` may be used where `Material` is expected, because knowing which state a material is in is never a problem. The reverse is refused, which is how transforming into cells nobody made competent is caught. The state constrains the argument rather than wrapping the material, so a narrowed material is owned, consumed, and tracked exactly like any other. + +A state carrying fields requires them wherever it is stated, so `competence = competent` states an `efficiency` beside it. What is true of a material in a state is part of saying it is in that state. + +A measurement composes with another, and the result measures something neither operand measured: + +```lab +tryptone = 10 g/L * 500 mL // 5 g +volume = 500 ng / 100 ng/uL // a volume, in litres +length = 12 kb in bp // 12000 bp +``` + +The result lands in the canonical unit of what it measures, and `in` converts where a conversion is written. A field may ask for a dimension rather than a unit, so `Quantity` takes any volume and `Quantity` takes any mass in a volume. Amount in a volume is a different thing, so a field asking for a concentration refuses millimolar: going between them needs a molar mass, which is a fact about the substance rather than about the recipe. + These are the mechanics. The *vocabulary* — `plasmid`, `strain`, and any domain word a package declares with `artifact` — is not in this table and is not in the parser, which is the point of [0022](decisions/0022-fixed-grammar-open-vocabulary.md). Circuits and workflows both declare a callable signature in their header. Laboratory verbs such as `synthesize`, `assemble`, `sequence`, `store`, and `dispose` are library operations, not keywords. The core punctuation has one job each: @@ -471,6 +500,8 @@ plasmid p_gfp: 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. +The same standard holds wherever two measurements meet. `20 uL + 5 mL` and `volume > 5 mL` where volume is in microlitres are diagnostics, because neither unit converts on its own. A measurement scales by a plain number, so `20 uL * 3` is a volume and states a batch. Two measurements multiplied give a quantity in neither operand's unit, which is refused until a quantity's dimension is computed. + ## Evidence a claim is believed on Three colonies picked from a plate are independent transformants; one culture diff --git a/examples/golden-gate-extended/inventory/facility.ttl b/examples/golden-gate-extended/inventory/facility.ttl index 1ebbce42..088bfad5 100644 --- a/examples/golden-gate-extended/inventory/facility.ttl +++ b/examples/golden-gate-extended/inventory/facility.ttl @@ -414,6 +414,12 @@ materials:BL21 sbol:hasNamespace ; sbol:type . +materials:LB_chloramphenicol_agar + a sbol:Component ; + sbol:displayId "LB_chloramphenicol_agar" ; + sbol:hasNamespace ; + sbol:type . + materials:chloramphenicol a sbol:Component ; sbol:displayId "chloramphenicol" ; @@ -614,6 +620,16 @@ lots:BL21_lot fac:locatedIn ex:stock_storage ; fac:isActive true . +lots:LB_chloramphenicol_agar_lot + a sbol:Implementation ; + sbol:displayId "LB_chloramphenicol_agar_lot" ; + sbol:hasNamespace ; + sbol:built materials:LB_chloramphenicol_agar ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + lots:chloramphenicol_lot a sbol:Implementation ; sbol:displayId "chloramphenicol_lot" ; diff --git a/examples/golden-gate-extended/src/designs/inventory.lab b/examples/golden-gate-extended/src/designs/inventory.lab index 0ed2816c..af982fa3 100644 --- a/examples/golden-gate-extended/src/designs/inventory.lab +++ b/examples/golden-gate-extended/src/designs/inventory.lab @@ -42,6 +42,8 @@ buy: // Both are transformed the way competent cells are: chilled, shocked, recovered. chassis DH5alpha: sbol_identity = "https://example.org/golden-gate/materials/DH5alpha" + competence = competent + efficiency = 1e9 cfu/ug heat_shock_temperature = 42 C cold_incubation = 30 min recovery_temperature = 37 C @@ -49,6 +51,8 @@ buy: chassis BL21: sbol_identity = "https://example.org/golden-gate/materials/BL21" + competence = competent + efficiency = 1e7 cfu/ug heat_shock_temperature = 42 C cold_incubation = 30 min recovery_temperature = 37 C @@ -56,3 +60,16 @@ buy: antibiotic chloramphenicol: sbol_identity = "https://example.org/golden-gate/materials/chloramphenicol" + + // Selective agar, poured and ready to spread on. A plate is a medium that + // has been poured, so what a culture is spread on says what it is made of. + medium LB_chloramphenicol_agar: + sbol_identity = "https://example.org/golden-gate/materials/LB_chloramphenicol_agar" + pouring = poured + selection = chloramphenicol + components = [ + Ingredient { substance: "tryptone", concentration: 10 g/L }, + Ingredient { substance: "yeast extract", concentration: 5 g/L }, + Ingredient { substance: "sodium chloride", concentration: 10 g/L }, + Ingredient { substance: "agar", concentration: 15 g/L }, + ] diff --git a/examples/golden-gate-extended/src/designs/plasmids.lab b/examples/golden-gate-extended/src/designs/plasmids.lab index 90dec589..344bd96f 100644 --- a/examples/golden-gate-extended/src/designs/plasmids.lab +++ b/examples/golden-gate-extended/src/designs/plasmids.lab @@ -56,7 +56,7 @@ build plasmid composite_plasmid_1: require topology == circular require sites(BsaI) == 0 - require length <= 12 kb + require length <= 12000 bp across 3 biological replicates diff --git a/examples/golden-gate-extended/src/workflows/build_strains.lab b/examples/golden-gate-extended/src/workflows/build_strains.lab index 8b15af63..a2014710 100644 --- a/examples/golden-gate-extended/src/workflows/build_strains.lab +++ b/examples/golden-gate-extended/src/workflows/build_strains.lab @@ -4,6 +4,7 @@ * onto selective agar. */ +use std.bio.designs use std.lab.plasmid use golden_gate_extended.designs.inventory use golden_gate_extended.designs.plasmids @@ -19,14 +20,15 @@ workflow build_composite_strain_1( composite_plasmid_1: Material, ) -> ( strain: Material, - plate: Material, + plate: Material, ): dependencies = [composite_plasmid_1] cells <- provision DH5alpha strain, culture <- transform composite_strain_1 from dependencies into cells culture <- recover culture for 1 h culture <- dilute culture - plate <- plate culture on chloramphenicol + agar <- provision LB_chloramphenicol_agar + plate <- plate culture on agar return strain, plate /** Build the RFP reporter in the same cloning strain. */ @@ -34,14 +36,15 @@ workflow build_composite_strain_2( composite_plasmid_2: Material, ) -> ( strain: Material, - plate: Material, + plate: Material, ): dependencies = [composite_plasmid_2] cells <- provision DH5alpha strain, culture <- transform composite_strain_2 from dependencies into cells culture <- recover culture for 1 h culture <- dilute culture - plate <- plate culture on chloramphenicol + agar <- provision LB_chloramphenicol_agar + plate <- plate culture on agar return strain, plate /** Build the GFP reporter in BL21, where it is expressed rather than stored. */ @@ -49,14 +52,15 @@ workflow build_expression_strain( composite_plasmid_1: Material, ) -> ( strain: Material, - plate: Material, + plate: Material, ): dependencies = [composite_plasmid_1] cells <- provision BL21 strain, culture <- transform expression_strain from dependencies into cells culture <- recover culture for 1 h culture <- dilute culture - plate <- plate culture on chloramphenicol + agar <- provision LB_chloramphenicol_agar + plate <- plate culture on agar return strain, plate /** @@ -69,7 +73,7 @@ workflow build_expression_strain( */ workflow build_reference_strain() -> ( strain: Material, - plate: Material, + plate: Material, ): // The binding takes the artifact's own name, which is how a build order // links a strain to the DNA that went into it. @@ -79,5 +83,6 @@ workflow build_reference_strain() -> ( strain, culture <- transform reference_strain from dependencies into cells culture <- recover culture for 1 h culture <- dilute culture - plate <- plate culture on chloramphenicol + agar <- provision LB_chloramphenicol_agar + plate <- plate culture on agar return strain, plate diff --git a/examples/golden-gate-extended/src/workflows/observe.lab b/examples/golden-gate-extended/src/workflows/observe.lab index eb40d733..ba843ada 100644 --- a/examples/golden-gate-extended/src/workflows/observe.lab +++ b/examples/golden-gate-extended/src/workflows/observe.lab @@ -7,6 +7,7 @@ * point past which nothing more will grow. */ +use std.bio.designs use std.lab.plasmid /** One image, what was counted in it, and how long the plate had been growing. */ @@ -22,7 +23,7 @@ record PlateObservation is Evidential: * evidence about the transformation rather than an absence of information. */ record ColonyGrowth: - plate: Material + plate: Material observations: List case Ready: @@ -36,7 +37,7 @@ record ColonyGrowth: * `state` is the memory this run owns: the observations accumulate across * firings of the timer, and both exits carry them out. */ -workflow await_colonies(plate: Material) -> ColonyGrowth: +workflow await_colonies(plate: Material) -> ColonyGrowth: state observations: List = [] diff --git a/examples/golden-gate-python/golden_gate/designs/inventory.py b/examples/golden-gate-python/golden_gate/designs/inventory.py index bb06463d..e238a310 100644 --- a/examples/golden-gate-python/golden_gate/designs/inventory.py +++ b/examples/golden-gate-python/golden_gate/designs/inventory.py @@ -10,8 +10,9 @@ Part, Promoter, RestrictionEnzyme, + competent, ) -from lab.units import C, minutes +from lab.units import C, cfu, minutes, ug module = lab.Module("golden_gate.designs.inventory", doc=__doc__) designs = sbol.Document(namespace="https://sbolcanvas.org") @@ -85,6 +86,8 @@ digest_duration=2 * minutes, ) DH5alpha = Chassis.buy( + competence=competent, + efficiency=10**9 * cfu / ug, sbol_identity="https://sbolcanvas.org/DH5alpha", heat_shock_temperature=42 * C, cold_incubation=30 * minutes, @@ -92,6 +95,8 @@ recovery_duration=60 * minutes, ) BL21 = Chassis.buy( + competence=competent, + efficiency=10**7 * cfu / ug, sbol_identity="https://sbolcanvas.org/BL21", heat_shock_temperature=42 * C, cold_incubation=30 * minutes, @@ -99,3 +104,8 @@ recovery_duration=60 * minutes, ) chloramphenicol = Antibiotic.buy() +LB_chloramphenicol_agar = Medium.buy( + sbol_identity="https://example.org/golden-gate/materials/LB_chloramphenicol_agar", + pouring=poured, + selection=chloramphenicol, +) diff --git a/examples/golden-gate-python/golden_gate/workflows/build_strains.py b/examples/golden-gate-python/golden_gate/workflows/build_strains.py index 19754020..501f40e9 100644 --- a/examples/golden-gate-python/golden_gate/workflows/build_strains.py +++ b/examples/golden-gate-python/golden_gate/workflows/build_strains.py @@ -1,7 +1,9 @@ """Cotransform, recover, dilute, and plate the engineered strain.""" import lab -from lab import Material, Plasmid, Plate, Strain +from lab.bio.designs import Medium, inoculated +from lab import Material, Plasmid +Strain from lab.units import h from ..designs.inventory import DH5alpha, chloramphenicol @@ -16,12 +18,13 @@ def build_GVD_strain( GVD0011: Material[Plasmid], GVD0013: Material[Plasmid], GVD0015: Material[Plasmid], -) -> tuple[Material[Strain], Material[Plate]]: +) -> tuple[Material[Strain], Material[inoculated[Medium]]]: cells = wf.perform(lab.provision(DH5alpha)) strain, culture = wf.perform( lab.transform(GVD_strain, plasmids=[GVD0011, GVD0013, GVD0015], cells=cells) ) culture = wf.perform(lab.recover(culture, duration=1 * h)) culture = wf.perform(lab.dilute(culture)) - plate = wf.perform(lab.plate(culture, antibiotic=chloramphenicol)) + agar = wf.perform(lab.provision(LB_chloramphenicol_agar)) + plate = wf.perform(lab.plate(culture, medium=agar)) return strain, plate diff --git a/examples/golden-gate/inventory/facility.ttl b/examples/golden-gate/inventory/facility.ttl index 71b33010..f77b3c87 100644 --- a/examples/golden-gate/inventory/facility.ttl +++ b/examples/golden-gate/inventory/facility.ttl @@ -409,6 +409,12 @@ canvas:DH5alpha sbol:hasNamespace ; sbol:type . +materials:LB_chloramphenicol_agar + a sbol:Component ; + sbol:displayId "LB_chloramphenicol_agar" ; + sbol:hasNamespace ; + sbol:type . + materials:chloramphenicol a sbol:Component ; sbol:displayId "chloramphenicol" ; @@ -587,6 +593,16 @@ lots:DH5alpha_lot fac:locatedIn ex:stock_storage ; fac:isActive true . +lots:LB_chloramphenicol_agar_lot + a sbol:Implementation ; + sbol:displayId "LB_chloramphenicol_agar_lot" ; + sbol:hasNamespace ; + sbol:built materials:LB_chloramphenicol_agar ; + fac:materialKind inv:ProcuredMaterial ; + fac:facility ex:facility ; + fac:locatedIn ex:stock_storage ; + fac:isActive true . + lots:chloramphenicol_lot a sbol:Implementation ; sbol:displayId "chloramphenicol_lot" ; diff --git a/examples/golden-gate/src/designs/inventory.lab b/examples/golden-gate/src/designs/inventory.lab index 4a63b3b6..aa9de198 100644 --- a/examples/golden-gate/src/designs/inventory.lab +++ b/examples/golden-gate/src/designs/inventory.lab @@ -71,6 +71,8 @@ buy: // Both are transformed the way competent cells are: chilled, shocked, recovered. chassis DH5alpha: sbol_identity = "https://sbolcanvas.org/DH5alpha" + competence = competent + efficiency = 1e9 cfu/ug heat_shock_temperature = 42 C cold_incubation = 30 min recovery_temperature = 37 C @@ -78,6 +80,8 @@ buy: chassis BL21: sbol_identity = "https://sbolcanvas.org/BL21" + competence = competent + efficiency = 1e7 cfu/ug heat_shock_temperature = 42 C cold_incubation = 30 min recovery_temperature = 37 C @@ -85,3 +89,16 @@ buy: antibiotic chloramphenicol: sbol_identity = "https://example.org/golden-gate/materials/chloramphenicol" + + // Selective agar, poured and ready to spread on. A plate is a medium that + // has been poured, so what a culture is spread on says what it is made of. + medium LB_chloramphenicol_agar: + sbol_identity = "https://example.org/golden-gate/materials/LB_chloramphenicol_agar" + pouring = poured + selection = chloramphenicol + components = [ + Ingredient { substance: "tryptone", concentration: 10 g/L }, + Ingredient { substance: "yeast extract", concentration: 5 g/L }, + Ingredient { substance: "sodium chloride", concentration: 10 g/L }, + Ingredient { substance: "agar", concentration: 15 g/L }, + ] diff --git a/examples/golden-gate/src/workflows/build_strains.lab b/examples/golden-gate/src/workflows/build_strains.lab index 80303d0a..f638e4bb 100644 --- a/examples/golden-gate/src/workflows/build_strains.lab +++ b/examples/golden-gate/src/workflows/build_strains.lab @@ -3,6 +3,7 @@ * cultures, serially dilute them, and spot the dilutions onto selective agar. */ +use std.bio.designs use std.lab.plasmid use golden_gate.designs.inventory use golden_gate.designs.strains @@ -13,12 +14,13 @@ workflow build_GVD_strain( GVD0015: Material, ) -> ( strain: Material, - plate: Material, + plate: Material, ): dependencies = [GVD0011, GVD0013, GVD0015] cells <- provision DH5alpha strain, culture <- transform GVD_strain from dependencies into cells culture <- recover culture for 1 h culture <- dilute culture - plate <- plate culture on chloramphenicol + agar <- provision LB_chloramphenicol_agar + plate <- plate culture on agar return strain, plate