From dff24977d0891f1abf277ff16111c829925a05ce Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 3 Sep 2026 21:40:28 -0600 Subject: [PATCH 01/14] Track the state a material is in A material's state was encoded as a separate type, which is why `Culture`, `Clone`, and `Plate` are fieldless: they are not kinds of thing, so there is no design underneath them to ask what is growing or what it grows in. LAIR already modelled states correctly and verified them, but the source language could not name one, so the state was assumed at the lowering site: every `provision` minted competent cells, and fetching an antibiotic produced a value the IR believed was a tube of cells. A `facet` declares the states a kind's materials may be in, the fields each state carries, and the transitions between them. Several facets classify one kind independently, so a culture that is both diluted and grown under selection is two facets rather than one state naming both. An instance states its state with the facet's name in snake_case, and a type argument narrows to one with the `is` that already says a type plays a role. Narrowing runs one way: `Material` may be used where `Material` is expected, never the reverse. That is what makes transforming into cells nobody made competent a diagnostic at the operand. The state rides on the type argument rather than wrapping the material, because ownership and linearity analysis find a material by its outermost name and both end in wildcards. Wrapping would have dropped exactly the state-carrying materials out of affine checking. Workflow LAIR's material type carries an absolute IRI instead of one of seven variants, and the table translating variants to IRIs is gone. The enumeration was already too narrow: `method::standard` mints three states it never had. A Method port may now say its state is the one its Intent asked for, which is what lets one provisioning signature serve every kind of thing a shelf holds. Unit safety now holds wherever two measurements meet, not only across an assignment. `20 uL + 5 mL` and `volume > 5 mL` were accepted and are now refused; two shipped files compared base pairs to kilobases. A measurement scales by a plain number, so `20 uL * 3` states a batch, and a quantity can be divided at all, which a unit denominator used to swallow. Mass and mass concentration exist as canonical Procedure quantities. Nothing constructs one yet; a medium recipe needs them and cannot lower without them. --- .../lab-compiler/src/allocation/validation.rs | 4 +- crates/lab-compiler/src/method/definition.rs | 21 + crates/lab-compiler/src/method/refinement.rs | 131 +++--- crates/lab-compiler/src/method/registry.rs | 43 +- crates/lab-compiler/src/method/standard.rs | 5 +- crates/lab-compiler/src/procedure/mod.rs | 3 +- .../src/procedure/quantity/concentration.rs | 79 ++++ .../src/procedure/quantity/mass.rs | 76 +++ .../src/procedure/quantity/mod.rs | 4 + .../lab-compiler/src/procedure/vocabulary.rs | 2 + crates/lab-compiler/src/program/lowering.rs | 46 +- crates/lab-compiler/src/program/mod.rs | 68 ++- crates/lab-compiler/src/workflow/ir.rs | 138 ++++-- crates/lab-ide/src/model.rs | 1 + crates/lab-ide/src/semantic.rs | 18 +- crates/lab-language-server/src/features.rs | 3 + crates/lab-language/src/ast.rs | 68 ++- crates/lab-language/src/checked.rs | 62 ++- crates/lab-language/src/checker/context.rs | 82 ++++ .../lab-language/src/checker/declarations.rs | 315 ++++++++++++- crates/lab-language/src/checker/expr.rs | 93 +++- crates/lab-language/src/checker/interface.rs | 31 +- crates/lab-language/src/checker/mod.rs | 436 +++++++++++++++++- crates/lab-language/src/parser.rs | 101 +++- crates/lab-language/src/provenance.rs | 3 +- crates/lab-language/src/render.rs | 9 + .../lab-language/src/semantics/interface.rs | 17 + crates/lab-language/src/semantics/mod.rs | 2 +- .../src/standard_library/authored/designs.lab | 20 + .../src/standard_library/lab/plasmid.rs | 12 +- .../src/standard_library/manifest.rs | 20 + crates/lab-language/src/type_system.rs | 44 +- crates/lab-python/python/lab/bio/designs.py | 21 +- crates/lab-python/python/lab/codegen.py | 28 +- .../tests/programs/golden_gate/inventory.py | 13 +- .../tests/programs/reporter/workflow.py | 3 +- docs/README.md | 3 + docs/language/README.md | 3 + ...052-material-states-are-declared-facets.md | 108 +++++ ...quantities-carry-dimensions-and-compose.md | 72 +++ .../0054-every-material-carries-a-quantity.md | 76 +++ docs/language/specimens/dependency-build.lab | 3 +- docs/language/specimens/inventory-plasmid.lab | 3 +- docs/language/specimens/plasmid-build.lab | 3 +- docs/language/specimens/plasmid-design.lab | 2 +- docs/language/syntax.md | 23 +- .../src/designs/inventory.lab | 2 + .../src/designs/plasmids.lab | 2 +- .../golden_gate/designs/inventory.py | 4 +- .../golden-gate/src/designs/inventory.lab | 2 + 50 files changed, 2167 insertions(+), 161 deletions(-) create mode 100644 crates/lab-compiler/src/procedure/quantity/concentration.rs create mode 100644 crates/lab-compiler/src/procedure/quantity/mass.rs create mode 100644 docs/language/decisions/0052-material-states-are-declared-facets.md create mode 100644 docs/language/decisions/0053-quantities-carry-dimensions-and-compose.md create mode 100644 docs/language/decisions/0054-every-material-carries-a-quantity.md 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/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/refinement.rs b/crates/lab-compiler/src/method/refinement.rs index 3b33e6d6..b3b5e04b 100644 --- a/crates/lab-compiler/src/method/refinement.rs +++ b/crates/lab-compiler/src/method/refinement.rs @@ -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( @@ -131,10 +128,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"); @@ -468,6 +469,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 +511,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 +834,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 +872,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 +884,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..90e1c063 100644 --- a/crates/lab-compiler/src/method/standard.rs +++ b/crates/lab-compiler/src/method/standard.rs @@ -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( 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..b8968a47 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, @@ -52,6 +58,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"]; @@ -278,7 +297,8 @@ pub(crate) fn lower_build_intent( 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 flows = realization_flows(modules, &supplier_identities, &catalog_types)?; let context = BuildLoweringContext { flows: &flows, supplier_identities: &supplier_identities, @@ -528,6 +548,23 @@ fn inventory_properties( .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,6 +586,7 @@ fn supplier_identities(modules: &[&CheckedModule]) -> BTreeMap { fn realization_flows( modules: &[&CheckedModule], identities: &BTreeMap, + catalog_types: &BTreeMap, ) -> Result, SourceLoweringError> { let mut result = BTreeMap::new(); for declaration in declarations(modules) { @@ -603,10 +641,12 @@ 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)?; actions.push(WorkflowActionIntent::Provision { cells: cells.clone(), item, + state: provisioned_state(catalog_types.get(&declared)), }); } "std.lab.plasmid.transform" => { @@ -809,7 +849,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..6e0faad6 100644 --- a/crates/lab-compiler/src/program/mod.rs +++ b/crates/lab-compiler/src/program/mod.rs @@ -358,8 +358,8 @@ fn append_workflow( 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); } @@ -620,6 +620,7 @@ buy restriction_enzyme BsaI: sbol_identity = "https://SBOL2Build.org/BsaI" buy chassis DH5alpha: sbol_identity = "https://sbolcanvas.org/DH5alpha" + competence = competent buy antibiotic chloramphenicol: sbol_identity = "https://example.org/golden-gate/materials/chloramphenicol" buy part T4_DNA_ligase: @@ -713,6 +714,57 @@ 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. + #[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 +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 +783,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\"")); diff --git a/crates/lab-compiler/src/workflow/ir.rs b/crates/lab-compiler/src/workflow/ir.rs index a0af77cb..d74abb85 100644 --- a/crates/lab-compiler/src/workflow/ir.rs +++ b/crates/lab-compiler/src/workflow/ir.rs @@ -4,6 +4,7 @@ //! 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::common_traits::Verify; use pliron::context::Context; @@ -12,9 +13,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 +24,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(()) } } @@ -72,7 +95,7 @@ impl RealizeOp { op: Operation::new( ctx, Self::get_concrete_op_info(), - vec![MaterialType::PlasmidProduct.get(ctx)], + vec![MaterialType::state(ctx, "PlasmidProduct")], vec![design], vec![], 0, @@ -163,7 +186,7 @@ impl Verify for RealizeOp { } require_material( self.get_result_product(ctx), - MaterialType::PlasmidProduct, + "PlasmidProduct", self.loc(ctx), ctx, ) @@ -176,16 +199,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 +232,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 +281,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 +335,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 +391,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 +452,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 +498,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 +553,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 +603,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,16 +665,11 @@ 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, - self.loc(ctx), - ctx, - ) + require_material(self.get_result_plate(ctx), "Plate", self.loc(ctx), ctx) } } @@ -663,21 +689,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-ide/src/model.rs b/crates/lab-ide/src/model.rs index 5fc364ed..dae8b943 100644 --- a/crates/lab-ide/src/model.rs +++ b/crates/lab-ide/src/model.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; pub enum SymbolKind { Module, Role, + Facet, Circuit, Artifact, Data, diff --git a/crates/lab-ide/src/semantic.rs b/crates/lab-ide/src/semantic.rs index 288659e4..2888bdfb 100644 --- a/crates/lab-ide/src/semantic.rs +++ b/crates/lab-ide/src/semantic.rs @@ -5,15 +5,16 @@ 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", "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::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 +31,7 @@ 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::ArtifactKind(item) => item.doc.as_deref(), ast::Item::Circuit(item) => item.doc.as_deref(), ast::Item::Artifact(item) => item.doc.as_deref(), @@ -181,6 +183,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 +215,11 @@ 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 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..07fbc40c 100644 --- a/crates/lab-language-server/src/features.rs +++ b/crates/lab-language-server/src/features.rs @@ -302,6 +302,9 @@ 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, 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..35da743b 100644 --- a/crates/lab-language/src/ast.rs +++ b/crates/lab-language/src/ast.rs @@ -46,6 +46,7 @@ pub fn instance_word(type_name: &str) -> String { pub enum Item { Use(UseDecl), Role(RoleDecl), + Facet(FacetDecl), ArtifactKind(ArtifactKindDecl), Circuit(CircuitDecl), Artifact(ArtifactDecl), @@ -59,6 +60,7 @@ impl Item { match self { Self::Use(item) => item.span, Self::Role(item) => item.span, + Self::Facet(item) => item.span, Self::ArtifactKind(item) => item.span, Self::Circuit(item) => item.span, Self::Artifact(item) => item.span, @@ -95,6 +97,57 @@ pub struct UseDecl { 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, @@ -493,13 +546,26 @@ 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 + } } } } diff --git a/crates/lab-language/src/checked.rs b/crates/lab-language/src/checked.rs index f4655558..79af2d9e 100644 --- a/crates/lab-language/src/checked.rs +++ b/crates/lab-language/src/checked.rs @@ -19,12 +19,15 @@ 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, and a type argument may be narrowed +/// to one of those states. /// /// 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.v10"; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CheckedModule { @@ -59,6 +62,23 @@ 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 name a supplier lists, and the Lab type it stands for. /// /// Biological identity and supplier identity are separate fields rather @@ -172,6 +192,13 @@ pub enum CheckedType { Any { role: 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 +207,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 +239,7 @@ impl CheckedType { Self::List { element } => format!("List<{}>", element.display_name()), Self::Quantity { unit } => format!("Quantity<{unit}>"), Self::Any { role } => format!("any {role}"), + 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 +317,23 @@ pub struct CheckedSchemaField { pub optional: bool, } +/// 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, diff --git a/crates/lab-language/src/checker/context.rs b/crates/lab-language/src/checker/context.rs index 1de23ea4..8426822a 100644 --- a/crates/lab-language/src/checker/context.rs +++ b/crates/lab-language/src/checker/context.rs @@ -65,6 +65,31 @@ pub(super) struct ArtifactKindSignature { pub declares: Option, } +/// 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, @@ -104,6 +129,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 { @@ -141,6 +174,8 @@ impl SemanticContext { roles: BTreeSet::new(), type_roles: HashMap::new(), role_terms: HashMap::new(), + facets: HashMap::new(), + type_facets: HashMap::new(), } } @@ -314,6 +349,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 => { diff --git a/crates/lab-language/src/checker/declarations.rs b/crates/lab-language/src/checker/declarations.rs index 7ef5a81a..23928c62 100644 --- a/crates/lab-language/src/checker/declarations.rs +++ b/crates/lab-language/src/checker/declarations.rs @@ -5,8 +5,8 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use crate::ast::{ - ArtifactDecl, ArtifactMember, CircuitDecl, DataDecl, Expr, FieldDecl, Item, Module, Path, - Provenance, TypeArgument, TypeExpr, WorkflowOutputs, + ArtifactDecl, ArtifactMember, CircuitDecl, DataDecl, Expr, FacetDecl, FieldDecl, Item, Module, + Path, Provenance, TypeArgument, TypeExpr, WorkflowOutputs, instance_word, }; use crate::checked::{ CheckedAcceptance, CheckedCase, CheckedDeclaration, CheckedPresence, CheckedProperty, @@ -19,8 +19,8 @@ use crate::type_system::{Ty, to_checked_type}; use super::Checker; use super::context::{ - ArtifactKindSignature, CircuitSignature, DataSignature, Generics, SchemaField, - WorkflowSignature, + ArtifactKindSignature, CircuitSignature, DataSignature, FacetSignature, FacetState, Generics, + SchemaField, WorkflowSignature, }; /// Where a type parameter's name appears in a signature, in source order. @@ -65,6 +65,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), } } @@ -155,6 +158,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 +252,7 @@ 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::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 +310,17 @@ 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)?; + } + } + for item in &module.items { match item { Item::Circuit(declaration) => { @@ -289,7 +347,8 @@ impl Checker { }, ); } - Item::Role(_) => {} + // Both are collected in passes of their own. + Item::Role(_) | Item::Facet(_) => {} Item::ArtifactKind(declaration) => { let produces = self.lower_kind_type(&declaration.produces)?; // A kind's roles classify the type it produces, because @@ -467,11 +526,233 @@ 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) + } + + /// 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 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(()) } @@ -994,6 +1275,13 @@ 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. + // 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) { let mut error = SemanticError::new( property.name.span, @@ -1107,7 +1395,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()), @@ -1217,6 +1513,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); diff --git a/crates/lab-language/src/checker/expr.rs b/crates/lab-language/src/checker/expr.rs index 5565c220..f34d2921 100644 --- a/crates/lab-language/src/checker/expr.rs +++ b/crates/lab-language/src/checker/expr.rs @@ -209,7 +209,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 +225,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,19 +240,57 @@ 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( + 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: it yields a quantity in neither + // operand's unit, and until dimensions are computed the + // honest answer is that it cannot be written. + 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)) => { + Err(SemanticError::new( + *span, + format!( + "cannot multiply or divide {left} by {right}" + ), + ) + .help(format!( + "the result is measured in neither '{left_unit}' nor '{right_unit}', and a quantity's dimension is not yet computed" + )) + .help( + "scale a measurement by a plain number instead, such as '20 uL * 3'", + )) + } + _ if self.comparable(&left, &right) => Ok(left), + _ => Err(SemanticError::new( *span, - "incompatible arithmetic operands", - )) + format!("cannot combine {left} with {right} arithmetically"), + )), } } BinaryOp::Range => Ok(Ty::List(Box::new(left))), @@ -692,6 +740,25 @@ pub(super) fn numeric_text(expression: &Expr) -> Result { } } +/// 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..20284421 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, + CallableSignature, DefinitionId, ExportKind, FacetSurface, ModuleExport, ModuleId, + ModuleInterface, TypeParameters, }; pub(super) fn build_interface( @@ -33,6 +33,7 @@ pub(super) fn build_interface( roles: Vec::new(), term: None, schema: None, + facet: None, parameters: TypeParameters::default(), documentation: documentation.clone().unwrap_or_default(), }, @@ -74,6 +75,32 @@ pub(super) fn build_interface( BTreeMap::new(), doc, ), + 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..c73e7d12 100644 --- a/crates/lab-language/src/checker/mod.rs +++ b/crates/lab-language/src/checker/mod.rs @@ -79,6 +79,42 @@ 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::ArtifactKind(declaration) => { let signature = self .artifact_kinds @@ -1970,7 +2006,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.v10"); 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 +2426,88 @@ buy reagent BsaI: ); } + /// 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. Returning the left one was wrong; saying so is right until a + /// quantity's dimension is computed. + #[test] + fn refuses_multiplying_one_measurement_by_another() { + let error = refuses(" a = 20 uL * 5 uL\n"); + assert!( + error.contains("cannot multiply or divide"), + "a volume times a volume is not a volume: {error}" + ); + } + } + #[test] fn requires_every_field_a_schema_does_not_mark_optional() { let error = compile_module( @@ -2486,4 +2604,320 @@ 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#"artifact Culture + +facet Dilution on Culture: + neat + diluted + + neat -> diluted + +facet Selection on Culture: + 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 + +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/parser.rs b/crates/lab-language/src/parser.rs index 4f7bdf44..57517a5b 100644 --- a/crates/lab-language/src/parser.rs +++ b/crates/lab-language/src/parser.rs @@ -61,6 +61,10 @@ 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("circuit") { let mut declaration = self.parse_circuit()?; declaration.doc = doc; @@ -147,6 +151,80 @@ 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), + }) + } + /// 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 +1008,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); } @@ -1002,7 +1086,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 { diff --git a/crates/lab-language/src/provenance.rs b/crates/lab-language/src/provenance.rs index b6106c65..af155c2d 100644 --- a/crates/lab-language/src/provenance.rs +++ b/crates/lab-language/src/provenance.rs @@ -314,7 +314,8 @@ mod tests { const SETUP: &str = r#"use std.lab.plasmid use std.bio.designs -buy chassis DH5alpha +buy chassis DH5alpha: + competence = competent buy antibiotic chloramphenicol strain host: diff --git a/crates/lab-language/src/render.rs b/crates/lab-language/src/render.rs index eefdda47..a683c4bc 100644 --- a/crates/lab-language/src/render.rs +++ b/crates/lab-language/src/render.rs @@ -32,6 +32,15 @@ pub fn render_checked_module(module: &CheckedModule) -> String { acceptance.len() )), CheckedDeclaration::Role { name, .. } => output.push_str(&format!(" - role {name}\n")), + 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..3f0243d5 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,11 @@ 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, /// 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 +68,17 @@ pub struct ArtifactSchema { pub declares: Option, } +/// 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..f101f1f8 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, + ArtifactSchema, CallableSignature, ExportKind, FacetSurface, ModuleExport, ModuleInterface, SemanticEnvironment, TypeParameters, }; diff --git a/crates/lab-language/src/standard_library/authored/designs.lab b/crates/lab-language/src/standard_library/authored/designs.lab index dc39a20b..efc82e9c 100644 --- a/crates/lab-language/src/standard_library/authored/designs.lab +++ b/crates/lab-language/src/standard_library/authored/designs.lab @@ -86,6 +86,26 @@ artifact Chassis is FunctionalEntity: recovery_temperature?: Quantity recovery_duration?: Quantity +/** + * 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 diff --git a/crates/lab-language/src/standard_library/lab/plasmid.rs b/crates/lab-language/src/standard_library/lab/plasmid.rs index 657ba094..89b48471 100644 --- a/crates/lab-language/src/standard_library/lab/plasmid.rs +++ b/crates/lab-language/src/standard_library/lab/plasmid.rs @@ -86,7 +86,17 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { take, ), PhrasePart::Word("into"), - operand("cells", concrete(material(named("Chassis"))), take), + // 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, + ), ], results: vec![ begins("strain", concrete(material(named("Strain")))), diff --git a/crates/lab-language/src/standard_library/manifest.rs b/crates/lab-language/src/standard_library/manifest.rs index 8a80e0cc..21a708c5 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,19 @@ fn authored_export(name: &str, export: &ModuleExport) -> Option { .map_or_else(String::new, |output| output.r#type.display_name()), }) } + 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 => None, } } diff --git a/crates/lab-language/src/type_system.rs b/crates/lab-language/src/type_system.rs index d89faabc..0d3f0f82 100644 --- a/crates/lab-language/src/type_system.rs +++ b/crates/lab-language/src/type_system.rs @@ -22,6 +22,12 @@ pub(crate) enum Ty { /// constrained to a role. `Circuit` is a circuit /// driven by some signal nobody may name again. Any(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 +72,7 @@ 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::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 +100,10 @@ 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::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 +127,9 @@ 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::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, @@ -163,6 +177,18 @@ pub(crate) fn compatible(roles: &RoleTable, actual: &Ty, expected: &Ty) -> bool 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 +198,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 +232,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-python/python/lab/bio/designs.py b/crates/lab-python/python/lab/bio/designs.py index 1e76c16f..03a9d27d 100644 --- a/crates/lab-python/python/lab/bio/designs.py +++ b/crates/lab-python/python/lab/bio/designs.py @@ -15,7 +15,7 @@ from typing import Generic, TypeVar from .._types import LabType -from .._vocabulary import ArtifactKind +from .._vocabulary import ArtifactKind, Symbol _T1 = TypeVar("_T1") _T2 = TypeVar("_T2") @@ -35,6 +35,25 @@ 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. +""" + + +naive = Symbol(name="naive", uses=("std.bio.designs",)) + + +competent = Symbol(name="competent", uses=("std.bio.designs",)) + + class Operon(LabType, Generic[_T1, _T2]): """Two products expressed from one promoter. diff --git a/crates/lab-python/python/lab/codegen.py b/crates/lab-python/python/lab/codegen.py index 1f6b0573..ada99602 100644 --- a/crates/lab-python/python/lab/codegen.py +++ b/crates/lab-python/python/lab/codegen.py @@ -213,7 +213,9 @@ 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 "type" in kinds: types.add("LabType") @@ -273,6 +275,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 +287,28 @@ 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(_symbol(state, uses) for state in export.get("states") or ()) + return "\n\n\n".join(blocks) + + +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: diff --git a/crates/lab-python/tests/programs/golden_gate/inventory.py b/crates/lab-python/tests/programs/golden_gate/inventory.py index fa78cba1..64363f74 100644 --- a/crates/lab-python/tests/programs/golden_gate/inventory.py +++ b/crates/lab-python/tests/programs/golden_gate/inventory.py @@ -7,7 +7,16 @@ import lab from lab import dna -from lab.bio.designs import CDS, Antibiotic, Backbone, Chassis, Part, Promoter, RestrictionEnzyme +from lab.bio.designs import ( + Antibiotic, + Backbone, + CDS, + Chassis, + Part, + Promoter, + RestrictionEnzyme, + competent, +) from lab.units import C, minutes module = lab.Module("golden_gate.designs.inventory", doc=__doc__) @@ -119,6 +128,7 @@ # 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, sbol_identity="https://sbolcanvas.org/DH5alpha", heat_shock_temperature=42 * C, cold_incubation=30 * minutes, @@ -127,6 +137,7 @@ ) BL21 = Chassis.buy( + competence=competent, sbol_identity="https://sbolcanvas.org/BL21", heat_shock_temperature=42 * C, cold_incubation=30 * minutes, diff --git a/crates/lab-python/tests/programs/reporter/workflow.py b/crates/lab-python/tests/programs/reporter/workflow.py index 9c261a7e..fa5ff4fa 100644 --- a/crates/lab-python/tests/programs/reporter/workflow.py +++ b/crates/lab-python/tests/programs/reporter/workflow.py @@ -2,7 +2,7 @@ import lab from lab import Material, Plate -from lab.bio.designs import Antibiotic, Chassis, Strain +from lab.bio.designs import Antibiotic, Chassis, Strain, competent from lab.units import C, h, minutes from .plasmid import reporter @@ -10,6 +10,7 @@ module = lab.Module("reporter.workflow", doc=__doc__) DH5alpha = Chassis.buy( + competence=competent, identity="ATCC-53868", heat_shock_temperature=42 * C, recovery_duration=60 * minutes, 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..d8b8825d --- /dev/null +++ b/docs/language/decisions/0052-material-states-are-declared-facets.md @@ -0,0 +1,108 @@ +# 0052: Material states are declared facets, not separate kinds + +## Status + +Accepted, partially implemented. 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). + +Facets are declared, exported, and narrow a material. A facet state's declared fields are carried but +never required at a declaration, so a state cannot yet stand as an acceptance criterion. + +## 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, once a cultivation facet exists to replace them. + A culture becomes `Material` in a state, so it knows its organism from its type argument + and its medium from the state's fields. They remain fieldless prelude types until then, and the + 27 sites that name them span the Python SDK and the OT-2 adapter as well as the compiler. +- 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..118a9101 --- /dev/null +++ b/docs/language/decisions/0053-quantities-carry-dimensions-and-compose.md @@ -0,0 +1,72 @@ +# 0053: Quantities carry dimensions and compose + +## Status + +Accepted, partially implemented. Amends +[0025: A quantity type names the unit it is measured in](0025-quantity-types.md). + +Unit safety in arithmetic and comparison holds, and `Mass` and `MassConcentration` exist as canonical +Procedure quantities. Dimensions, written conversion, and dimensional arithmetic do not. + +## 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; +a transformation buffer is 50 mM calcium chloride. One component list cannot hold both, because a +field pins one unit and a recipe is not written in one unit. + +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. A recipe holds `10 g/L` and `50 mM` in one list because the field asks for a +concentration rather than for millimolar. + +**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..e5b76116 100644 --- a/docs/language/specimens/dependency-build.lab +++ b/docs/language/specimens/dependency-build.lab @@ -18,7 +18,8 @@ buy: backbone region_receiver restriction_enzyme BsaI restriction_enzyme BsmBI - chassis DH5alpha + chassis DH5alpha: + competence = competent antibiotic chloramphenicol build plasmid promoter_carrier: diff --git a/docs/language/specimens/inventory-plasmid.lab b/docs/language/specimens/inventory-plasmid.lab index 0a696363..054eef33 100644 --- a/docs/language/specimens/inventory-plasmid.lab +++ b/docs/language/specimens/inventory-plasmid.lab @@ -16,7 +16,8 @@ buy: part B0015 backbone pSB1C3 restriction_enzyme BsaI - chassis DH5alpha + chassis DH5alpha: + competence = competent antibiotic chloramphenicol reporter_sequence: DNA = dna("ACGTACGT") diff --git a/docs/language/specimens/plasmid-build.lab b/docs/language/specimens/plasmid-build.lab index 7ebc1b3f..48a5d5f6 100644 --- a/docs/language/specimens/plasmid-build.lab +++ b/docs/language/specimens/plasmid-build.lab @@ -4,7 +4,8 @@ use std.bio.golden_gate use std.lab.plasmid buy: - chassis competent_ecoli + chassis competent_ecoli: + competence = competent antibiotic kanamycin restriction_enzyme BsaI backbone p15A_kan 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..1984157b 100644 --- a/docs/language/syntax.md +++ b/docs/language/syntax.md @@ -51,14 +51,31 @@ 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. + 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 +488,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/src/designs/inventory.lab b/examples/golden-gate-extended/src/designs/inventory.lab index 0ed2816c..75470d1a 100644 --- a/examples/golden-gate-extended/src/designs/inventory.lab +++ b/examples/golden-gate-extended/src/designs/inventory.lab @@ -42,6 +42,7 @@ 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 heat_shock_temperature = 42 C cold_incubation = 30 min recovery_temperature = 37 C @@ -49,6 +50,7 @@ buy: chassis BL21: sbol_identity = "https://example.org/golden-gate/materials/BL21" + competence = competent heat_shock_temperature = 42 C cold_incubation = 30 min recovery_temperature = 37 C 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-python/golden_gate/designs/inventory.py b/examples/golden-gate-python/golden_gate/designs/inventory.py index bb06463d..ae5f5964 100644 --- a/examples/golden-gate-python/golden_gate/designs/inventory.py +++ b/examples/golden-gate-python/golden_gate/designs/inventory.py @@ -2,7 +2,7 @@ import lab from lab import sbol -from lab.bio.designs import ( +from lab.bio.designs import (, competent CDS, Antibiotic, Backbone, @@ -85,6 +85,7 @@ digest_duration=2 * minutes, ) DH5alpha = Chassis.buy( + competence=competent, sbol_identity="https://sbolcanvas.org/DH5alpha", heat_shock_temperature=42 * C, cold_incubation=30 * minutes, @@ -92,6 +93,7 @@ recovery_duration=60 * minutes, ) BL21 = Chassis.buy( + competence=competent, sbol_identity="https://sbolcanvas.org/BL21", heat_shock_temperature=42 * C, cold_incubation=30 * minutes, diff --git a/examples/golden-gate/src/designs/inventory.lab b/examples/golden-gate/src/designs/inventory.lab index 4a63b3b6..82a7e9ff 100644 --- a/examples/golden-gate/src/designs/inventory.lab +++ b/examples/golden-gate/src/designs/inventory.lab @@ -71,6 +71,7 @@ buy: // Both are transformed the way competent cells are: chilled, shocked, recovered. chassis DH5alpha: sbol_identity = "https://sbolcanvas.org/DH5alpha" + competence = competent heat_shock_temperature = 42 C cold_incubation = 30 min recovery_temperature = 37 C @@ -78,6 +79,7 @@ buy: chassis BL21: sbol_identity = "https://sbolcanvas.org/BL21" + competence = competent heat_shock_temperature = 42 C cold_incubation = 30 min recovery_temperature = 37 C From e27b15c629d834a4ab1e441fef4754fda56fa14d Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 3 Sep 2026 21:57:24 -0600 Subject: [PATCH 02/14] Repair the Python imports a scripted edit mangled The regex that added `competent` to each chassis declaration assumed a single-line import and produced `from lab.bio.designs import (, competent` in the Golden Gate example, which is not valid Python, and left `CDS` sorted after `Antibiotic` where ruff wants all-caps names first. CI lints and runs only `crates/lab-python`, so the broken example parsed nowhere and the import order failed only once it reached GitHub. --- crates/lab-python/tests/programs/golden_gate/inventory.py | 2 +- .../golden-gate-python/golden_gate/designs/inventory.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/lab-python/tests/programs/golden_gate/inventory.py b/crates/lab-python/tests/programs/golden_gate/inventory.py index 64363f74..1c9f5209 100644 --- a/crates/lab-python/tests/programs/golden_gate/inventory.py +++ b/crates/lab-python/tests/programs/golden_gate/inventory.py @@ -8,9 +8,9 @@ import lab from lab import dna from lab.bio.designs import ( + CDS, Antibiotic, Backbone, - CDS, Chassis, Part, Promoter, diff --git a/examples/golden-gate-python/golden_gate/designs/inventory.py b/examples/golden-gate-python/golden_gate/designs/inventory.py index ae5f5964..7d1d392d 100644 --- a/examples/golden-gate-python/golden_gate/designs/inventory.py +++ b/examples/golden-gate-python/golden_gate/designs/inventory.py @@ -2,7 +2,7 @@ import lab from lab import sbol -from lab.bio.designs import (, competent +from lab.bio.designs import ( CDS, Antibiotic, Backbone, @@ -10,6 +10,7 @@ Part, Promoter, RestrictionEnzyme, + competent, ) from lab.units import C, minutes @@ -85,7 +86,7 @@ digest_duration=2 * minutes, ) DH5alpha = Chassis.buy( - competence=competent, + competence=competent, sbol_identity="https://sbolcanvas.org/DH5alpha", heat_shock_temperature=42 * C, cold_incubation=30 * minutes, @@ -93,7 +94,7 @@ recovery_duration=60 * minutes, ) BL21 = Chassis.buy( - competence=competent, + competence=competent, sbol_identity="https://sbolcanvas.org/BL21", heat_shock_temperature=42 * C, cold_incubation=30 * minutes, From dc8e5953fbde0b667be179c858f85da96f8ae180 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 3 Sep 2026 23:01:49 -0600 Subject: [PATCH 03/14] Close the gaps material states left open Five things were listed as unfinished when the state work landed. Each is closed here, and two of them turned out to be defects rather than absences. Measurements compose. `10 g/L * 500 mL` is `5 g`, computed where it is written, so a recipe states concentrations once and a build reads grams. A unit carries a dimension and a power of ten to its canonical unit, which keeps every conversion exact; durations convert by sixties in a table of their own. `in` converts where a conversion is written, so `12 kb in bp` is what a person means and what the field holds. A field may ask for a dimension instead of a unit. Mass in a volume and amount in a volume stay different things, because going between them needs a molar mass and that is a fact about the substance. Scientific notation lexes, because a transformation efficiency is 1e9 cfu/ug and writing that out is a row of zeros to miscount. 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. `Culture`, `Clone`, and `Plate` stop being types. A culture is a strain that has recovered, a colony is a strain that was isolated, and a plate is a medium that was poured and spread on. Each now names what it is made of, which is what none of them could do. Plating states the medium rather than an antibiotic beside it, so what a plate selects for is read from what it is. Two lineage defects fell out of that. A plate is a culture on agar, and counting the agar made one plate look like two independent organisms, so a contract may now name operands that carry no lineage. And fetching one shelf item twice minted two origins, which would have let a program claim replicates it does not have; naming the same thing twice fetches one thing. Mass and mass concentration are reachable from a method parameter rather than sitting unwired. --- crates/lab-cli/tests/project_workflow.rs | 8 +- crates/lab-compiler/src/method/standard.rs | 46 ++ crates/lab-compiler/src/program/lowering.rs | 59 ++- crates/lab-compiler/src/program/mod.rs | 11 +- crates/lab-language/src/ast.rs | 34 +- crates/lab-language/src/checked.rs | 5 + .../lab-language/src/checker/declarations.rs | 110 ++++- crates/lab-language/src/checker/expr.rs | 250 ++++++++-- crates/lab-language/src/checker/mod.rs | 166 ++++++- crates/lab-language/src/lexer.rs | 59 +++ crates/lab-language/src/lib.rs | 1 + crates/lab-language/src/parser.rs | 24 +- crates/lab-language/src/provenance.rs | 148 +++++- .../src/standard_library/authored/designs.lab | 66 +++ .../src/standard_library/bio/build.rs | 1 + .../src/standard_library/catalog.rs | 1 + .../src/standard_library/contract.rs | 8 + .../src/standard_library/lab/plasmid.rs | 64 ++- .../src/standard_library/prelude.rs | 13 +- crates/lab-language/src/type_system.rs | 20 + crates/lab-language/src/units.rs | 428 ++++++++++++++++++ crates/lab-python/python/lab/_expressions.py | 4 + crates/lab-python/python/lab/_prelude.py | 32 +- crates/lab-python/python/lab/_types.py | 57 ++- crates/lab-python/python/lab/_vocabulary.py | 31 +- crates/lab-python/python/lab/bio/designs.py | 98 +++- crates/lab-python/python/lab/codegen.py | 46 +- crates/lab-python/python/lab/plasmid.py | 4 +- .../tests/programs/golden_gate/inventory.py | 18 +- .../tests/programs/reporter/observe.py | 6 +- .../tests/programs/reporter/workflow.py | 17 +- crates/lab-python/tests/test_workflows.py | 10 +- ...052-material-states-are-declared-facets.md | 23 +- ...quantities-carry-dimensions-and-compose.md | 18 +- docs/language/specimens/dependency-build.lab | 10 +- docs/language/specimens/inventory-plasmid.lab | 10 +- docs/language/specimens/plasmid-build.lab | 12 +- docs/language/syntax.md | 12 + .../inventory/facility.ttl | 16 + .../src/designs/inventory.lab | 15 + .../src/workflows/build_strains.lab | 21 +- .../src/workflows/observe.lab | 5 +- .../golden_gate/designs/inventory.py | 9 +- .../golden_gate/workflows/build_strains.py | 9 +- examples/golden-gate/inventory/facility.ttl | 16 + .../golden-gate/src/designs/inventory.lab | 15 + .../src/workflows/build_strains.lab | 6 +- 47 files changed, 1853 insertions(+), 189 deletions(-) create mode 100644 crates/lab-language/src/units.rs diff --git a/crates/lab-cli/tests/project_workflow.rs b/crates/lab-cli/tests/project_workflow.rs index 152b5087..2d85881f 100644 --- a/crates/lab-cli/tests/project_workflow.rs +++ b/crates/lab-cli/tests/project_workflow.rs @@ -1634,9 +1634,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 +2185,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 +2204,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/method/standard.rs b/crates/lab-compiler/src/method/standard.rs index 90e1c063..82c654a7 100644 --- a/crates/lab-compiler/src/method/standard.rs +++ b/crates/lab-compiler/src/method/standard.rs @@ -749,6 +749,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") { @@ -966,6 +970,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/program/lowering.rs b/crates/lab-compiler/src/program/lowering.rs index b8968a47..70bb66e2 100644 --- a/crates/lab-compiler/src/program/lowering.rs +++ b/crates/lab-compiler/src/program/lowering.rs @@ -298,7 +298,8 @@ pub(crate) fn lower_build_intent( let stated = inventory_properties(modules); let bindings = binding_values(modules); let catalog_types = catalog_types(modules); - let flows = realization_flows(modules, &supplier_identities, &catalog_types)?; + let selections = selections(modules, &supplier_identities); + let flows = realization_flows(modules, &supplier_identities, &catalog_types, &selections)?; let context = BuildLoweringContext { flows: &flows, supplier_identities: &supplier_identities, @@ -548,6 +549,45 @@ 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. /// @@ -587,6 +627,7 @@ fn realization_flows( modules: &[&CheckedModule], identities: &BTreeMap, catalog_types: &BTreeMap, + selections: &BTreeMap, ) -> Result, SourceLoweringError> { let mut result = BTreeMap::new(); for declaration in declarations(modules) { @@ -610,6 +651,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; @@ -643,6 +688,7 @@ fn realization_flows( }; 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, @@ -696,7 +742,16 @@ 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 => { diff --git a/crates/lab-compiler/src/program/mod.rs b/crates/lab-compiler/src/program/mod.rs index 6e0faad6..42d5608b 100644 --- a/crates/lab-compiler/src/program/mod.rs +++ b/crates/lab-compiler/src/program/mod.rs @@ -621,8 +621,13 @@ buy restriction_enzyme 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: @@ -668,14 +673,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 "#; @@ -728,6 +734,7 @@ use std.lab.plasmid buy chassis DH5alpha: competence = competent + efficiency = 1e9 cfu/ug buy antibiotic chloramphenicol: sbol_identity = "https://example.org/cam" diff --git a/crates/lab-language/src/ast.rs b/crates/lab-language/src/ast.rs index 35da743b..d32e9c9a 100644 --- a/crates/lab-language/src/ast.rs +++ b/crates/lab-language/src/ast.rs @@ -508,7 +508,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 { @@ -570,6 +570,28 @@ impl TypeArgument { } } +/// 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}"), + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Path { pub segments: Vec, @@ -616,6 +638,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, @@ -641,6 +672,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 79af2d9e..af3468b2 100644 --- a/crates/lab-language/src/checked.rs +++ b/crates/lab-language/src/checked.rs @@ -192,6 +192,10 @@ 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. @@ -239,6 +243,7 @@ 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(), diff --git a/crates/lab-language/src/checker/declarations.rs b/crates/lab-language/src/checker/declarations.rs index 23928c62..bab14b3d 100644 --- a/crates/lab-language/src/checker/declarations.rs +++ b/crates/lab-language/src/checker/declarations.rs @@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use crate::ast::{ ArtifactDecl, ArtifactMember, CircuitDecl, DataDecl, Expr, FacetDecl, FieldDecl, Item, Module, - Path, Provenance, TypeArgument, TypeExpr, WorkflowOutputs, instance_word, + Path, Provenance, TypeArgument, TypeExpr, Unit, WorkflowOutputs, instance_word, }; use crate::checked::{ CheckedAcceptance, CheckedCase, CheckedDeclaration, CheckedPresence, CheckedProperty, @@ -107,6 +107,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, @@ -613,6 +614,34 @@ impl Checker { 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 @@ -1194,6 +1223,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; @@ -1282,14 +1328,20 @@ impl Checker { if stated_facet(self, &produces, &property.name.value).is_some() { continue; } - if !signature.fields.contains_key(&property.name.value) { + 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}'?")); } @@ -1379,6 +1431,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) { @@ -1457,7 +1544,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, diff --git a/crates/lab-language/src/checker/expr.rs b/crates/lab-language/src/checker/expr.rs index f34d2921..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 { @@ -258,41 +299,28 @@ impl Checker { } // Scaling a measurement by a count keeps its unit, which is // how a recipe states a batch. Multiplying two measurements - // is a different operation: it yields a quantity in neither - // operand's unit, and until dimensions are computed the - // honest answer is that it cannot be written. - 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)) => { - Err(SemanticError::new( - *span, - format!( - "cannot multiply or divide {left} by {right}" - ), - ) - .help(format!( - "the result is measured in neither '{left_unit}' nor '{right_unit}', and a quantity's dimension is not yet computed" - )) - .help( - "scale a measurement by a plain number instead, such as '20 uL * 3'", - )) - } - _ if self.comparable(&left, &right) => Ok(left), - _ => Err(SemanticError::new( - *span, - format!("cannot combine {left} with {right} arithmetically"), - )), + // 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))), } } @@ -740,6 +768,150 @@ 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, diff --git a/crates/lab-language/src/checker/mod.rs b/crates/lab-language/src/checker/mod.rs index c73e7d12..5599f295 100644 --- a/crates/lab-language/src/checker/mod.rs +++ b/crates/lab-language/src/checker/mod.rs @@ -763,21 +763,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 @@ -2426,6 +2428,135 @@ 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. @@ -2496,16 +2627,24 @@ buy reagent BsaI: } /// Two measurements multiplied give a quantity in neither operand's - /// unit. Returning the left one was wrong; saying so is right until a - /// quantity's dimension is computed. + /// 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_multiplying_one_measurement_by_another() { + fn refuses_a_product_with_no_unit_to_write_it_in() { let error = refuses(" a = 20 uL * 5 uL\n"); assert!( - error.contains("cannot multiply or divide"), + 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] @@ -2730,15 +2869,17 @@ facet Competence on Chassis: #[test] fn a_kind_carries_several_independent_facets() { let module = compile_module( - r#"artifact Culture + r#"record Broth + +artifact Broth -facet Dilution on Culture: +facet Dilution on Broth: neat diluted neat -> diluted -facet Selection on Culture: +facet Selection on Broth: permissive selective @@ -2874,6 +3015,7 @@ use std.lab.plasmid buy chassis DH5alpha: competence = competent + efficiency = 1e9 cfu/ug buy chassis Naive: competence = naive 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 57517a5b..2328aee8 100644 --- a/crates/lab-language/src/parser.rs +++ b/crates/lab-language/src/parser.rs @@ -1039,7 +1039,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, @@ -1214,6 +1222,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 af155c2d..de0297ff 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: &'static [&'static str], +} /// 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, + }, + ) }) .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,10 @@ 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.contains(&argument.name.as_str())) { + 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 +252,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 +273,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 +304,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 { .. }) } @@ -316,7 +375,11 @@ use std.bio.designs buy chassis DH5alpha: competence = competent + efficiency = 1e9 cfu/ug buy antibiotic chloramphenicol +buy medium LB_agar: + pouring = poured + selection = chloramphenicol strain host: chassis = DH5alpha @@ -327,6 +390,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] @@ -334,12 +444,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 "# ), @@ -362,11 +474,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 "# ), @@ -389,11 +504,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/standard_library/authored/designs.lab b/crates/lab-language/src/standard_library/authored/designs.lab index efc82e9c..0f4d4206 100644 --- a/crates/lab-language/src/standard_library/authored/designs.lab +++ b/crates/lab-language/src/standard_library/authored/designs.lab @@ -86,6 +86,51 @@ 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. * @@ -109,6 +154,27 @@ facet Competence on Chassis: /** 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..b69e4d90 100644 --- a/crates/lab-language/src/standard_library/bio/build.rs +++ b/crates/lab-language/src/standard_library/bio/build.rs @@ -33,6 +33,7 @@ pub(in crate::standard_library::bio) fn module() -> StandardModule { ], // Realizing a design assembles DNA rather than establishing an // organism, so the product carries the lineage of what went into it. + inert: &[], results: vec![ResultSpec { name: "product", r#type: concrete(material(named("Plasmid"))), diff --git a/crates/lab-language/src/standard_library/catalog.rs b/crates/lab-language/src/standard_library/catalog.rs index f7eb38d3..c5421650 100644 --- a/crates/lab-language/src/standard_library/catalog.rs +++ b/crates/lab-language/src/standard_library/catalog.rs @@ -715,6 +715,7 @@ mod tests { r#type: ContractType::Concrete(Ty::String), mode: crate::OwnershipMode::Copy, }], + inert: &[], 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..3f0fc725 100644 --- a/crates/lab-language/src/standard_library/contract.rs +++ b/crates/lab-language/src/standard_library/contract.rs @@ -85,6 +85,13 @@ pub(crate) struct ActionContractSpec { pub operation: &'static str, 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: &'static [&'static str], } impl ActionContractSpec { @@ -206,6 +213,7 @@ mod tests { ActionContractSpec { operation: "test.action", phrase, + inert: &[], results: Vec::new(), } } diff --git a/crates/lab-language/src/standard_library/lab/plasmid.rs b/crates/lab-language/src/standard_library/lab/plasmid.rs index 89b48471..116f713b 100644 --- a/crates/lab-language/src/standard_library/lab/plasmid.rs +++ b/crates/lab-language/src/standard_library/lab/plasmid.rs @@ -28,6 +28,13 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { 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 { @@ -36,8 +43,9 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { PhrasePart::Word("capture"), PhrasePart::Word("image"), PhrasePart::Word("of"), - operand("plate", concrete(material(named("Plate"))), borrow), + operand("plate", concrete(plate("inoculated")), borrow), ], + inert: &[], results: vec![result("image", concrete(named("Image")))], }, ActionContractSpec { @@ -46,6 +54,7 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { PhrasePart::Word("synthesize"), operand("design", concrete(named("Plasmid")), copy), ], + inert: &[], results: vec![result( "fragments", concrete(Ty::List(Box::new(named("Fragment")))), @@ -61,6 +70,7 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { take, ), ], + inert: &[], results: vec![result("construct", concrete(material(named("Plasmid"))))], }, ActionContractSpec { @@ -72,6 +82,7 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { // 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. + inert: &[], results: vec![result("material", ContractType::MaterialOf("item"))], }, ActionContractSpec { @@ -98,16 +109,17 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { take, ), ], + inert: &[], 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", phrase: vec![ PhrasePart::Word("recover"), - operand("culture", concrete(material(named("Culture"))), take), + operand("culture", concrete(strain("transformed")), take), PhrasePart::Word("for"), PhrasePart::Quantity { name: "duration", @@ -115,25 +127,38 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { units: &["min", "h"], }, ], - results: vec![result("culture", concrete(material(named("Culture"))))], + inert: &[], + results: vec![result("culture", concrete(strain("recovered")))], }, ActionContractSpec { operation: "std.lab.plasmid.dilute", phrase: vec![ PhrasePart::Word("dilute"), - operand("culture", concrete(material(named("Culture"))), take), + operand("culture", concrete(strain("recovered")), take), ], - results: vec![result("culture", concrete(material(named("Culture"))))], + inert: &[], + results: vec![result("culture", concrete(strain("diluted")))], }, ActionContractSpec { operation: "std.lab.plasmid.plate", phrase: vec![ PhrasePart::Word("plate"), - operand("culture", concrete(material(named("Culture"))), take), + // 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"), - operand("antibiotic", concrete(named("Antibiotic")), copy), + // 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: &["medium"], + results: vec![result("plate", concrete(plate("inoculated")))], }, ActionContractSpec { operation: "std.lab.plasmid.pick", @@ -146,11 +171,12 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { PhrasePart::Word("isolated"), PhrasePart::Word("colonies"), PhrasePart::Word("from"), - operand("plate", concrete(material(named("Plate"))), borrow), + operand("plate", concrete(plate("inoculated")), borrow), ], + inert: &[], results: vec![begins( "candidates", - concrete(Ty::List(Box::new(material(named("Clone"))))), + concrete(Ty::List(Box::new(strain("isolated")))), )], }, ActionContractSpec { @@ -159,19 +185,20 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { PhrasePart::Word("screen"), operand( "candidates", - concrete(Ty::List(Box::new(material(named("Clone"))))), + concrete(Ty::List(Box::new(strain("isolated")))), take, ), PhrasePart::Word("against"), operand("design", concrete(named("Plasmid")), copy), ], + inert: &[], results: vec![result("screening", concrete(named("Screening")))], }, ActionContractSpec { operation: "std.lab.plasmid.grow", phrase: vec![ PhrasePart::Word("grow"), - operand("clone", concrete(material(named("Clone"))), take), + operand("clone", concrete(strain("isolated")), take), PhrasePart::Word("at"), PhrasePart::Quantity { name: "temperature", @@ -185,14 +212,16 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { units: &["h"], }, ], - results: vec![result("culture", concrete(material(named("Culture"))))], + inert: &[], + results: vec![result("culture", concrete(strain("grown")))], }, ActionContractSpec { operation: "std.lab.plasmid.purify", phrase: vec![ PhrasePart::Word("purify"), - operand("culture", concrete(material(named("Culture"))), take), + operand("culture", concrete(strain("grown")), take), ], + inert: &[], results: vec![result("plasmid", concrete(material(named("Plasmid"))))], }, ActionContractSpec { @@ -201,6 +230,7 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { PhrasePart::Word("split"), operand("material", concrete(material(named("Plasmid"))), take), ], + inert: &[], results: vec![ result("retained", ContractType::SameAs("material")), result("aliquot", ContractType::SameAs("material")), @@ -212,6 +242,7 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { PhrasePart::Word("sequence"), operand("aliquot", concrete(material(named("Plasmid"))), take), ], + inert: &[], results: vec![result("result", concrete(named("SequenceCheck")))], }, ActionContractSpec { @@ -220,6 +251,7 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { PhrasePart::Word("quantify"), operand("material", concrete(material(named("Plasmid"))), borrow), ], + inert: &[], results: vec![result("evidence", concrete(named("Evidence")))], }, ActionContractSpec { @@ -234,6 +266,7 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { units: &["C"], }, ], + inert: &[], results: vec![result("material", ContractType::SameAs("material"))], }, ActionContractSpec { @@ -242,6 +275,7 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { PhrasePart::Word("dispose"), operand("material", ContractType::AnyMaterial, take), ], + inert: &[], results: Vec::new(), }, ]; diff --git a/crates/lab-language/src/standard_library/prelude.rs b/crates/lab-language/src/standard_library/prelude.rs index 6ed5c93e..874654d9 100644 --- a/crates/lab-language/src/standard_library/prelude.rs +++ b/crates/lab-language/src/standard_library/prelude.rs @@ -14,12 +14,15 @@ pub(in crate::standard_library) fn modules() -> Vec { 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 +33,8 @@ pub(in crate::standard_library) fn modules() -> Vec { TypeSpec::nominal("Image"), TypeSpec::nominal("List").parameters(1), TypeSpec::nominal("Material").parameters(1), + TypeSpec::nominal("Medium").documented("What an organism is grown in or on."), TypeSpec::nominal("Part"), - TypeSpec::nominal("Plate"), TypeSpec::nominal("Plasmid") .with_fields([ ("topology", named("Topology")), diff --git a/crates/lab-language/src/type_system.rs b/crates/lab-language/src/type_system.rs index 0d3f0f82..bce6cb95 100644 --- a/crates/lab-language/src/type_system.rs +++ b/crates/lab-language/src/type_system.rs @@ -22,6 +22,13 @@ 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 @@ -72,6 +79,7 @@ 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"), @@ -100,6 +108,9 @@ 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(), @@ -127,6 +138,7 @@ 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()) } @@ -171,6 +183,14 @@ 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), 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-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..68978db4 100644 --- a/crates/lab-python/python/lab/_prelude.py +++ b/crates/lab-python/python/lab/_prelude.py @@ -21,11 +21,9 @@ "Backbone", "Chassis", "Circuit", - "Clone", "CloneSet", "Colonies", "ColonyMap", - "Culture", "Duration", "Event", "Evidence", @@ -34,9 +32,9 @@ "Image", "List", "Material", + "Medium", "Part", "Plasmid", - "Plate", "Promoter", "Protein", "Reason", @@ -88,23 +86,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 +140,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 +185,7 @@ class RestrictionEnzyme(LabType): __lab_uses__ = () -class Screening(LabType): +class Screening(LabConstructor): __lab_uses__ = () @@ -204,7 +196,7 @@ class Signal(LabRole): __lab_uses__ = () -class Strain(LabType): +class Strain(LabConstructor): """A chassis carrying a defined set of plasmid designs.""" __lab_uses__ = () @@ -214,7 +206,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/bio/designs.py b/crates/lab-python/python/lab/bio/designs.py index 03a9d27d..f56a7ec1 100644 --- a/crates/lab-python/python/lab/bio/designs.py +++ b/crates/lab-python/python/lab/bio/designs.py @@ -14,7 +14,7 @@ from typing import Generic, TypeVar -from .._types import LabType +from .._types import LabConstructor, LabState, LabType from .._vocabulary import ArtifactKind, Symbol _T1 = TypeVar("_T1") @@ -48,10 +48,59 @@ class Both(LabType, Generic[_T1, _T2]): """ -naive = Symbol(name="naive", uses=("std.bio.designs",)) +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.""" -competent = Symbol(name="competent", uses=("std.bio.designs",)) + __lab_uses__ = ("std.bio.designs",) class Operon(LabType, Generic[_T1, _T2]): @@ -65,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.""" @@ -120,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 ada99602..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" @@ -217,9 +221,14 @@ def _runtime_imports(path: str, exports: list[dict[str, Any]], constructors: fro # 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") @@ -249,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, ) @@ -296,10 +310,27 @@ def _facet(export: dict[str, Any], uses: tuple[str, ...]) -> str: """ blocks = [_documented(_symbol(export["name"], uses), export)] - blocks.extend(_symbol(state, uses) for state in export.get("states") or ()) + 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: @@ -323,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/plasmid.py b/crates/lab-python/python/lab/plasmid.py index 803904ae..30da9b70 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. """ diff --git a/crates/lab-python/tests/programs/golden_gate/inventory.py b/crates/lab-python/tests/programs/golden_gate/inventory.py index 1c9f5209..245d4d59 100644 --- a/crates/lab-python/tests/programs/golden_gate/inventory.py +++ b/crates/lab-python/tests/programs/golden_gate/inventory.py @@ -12,12 +12,15 @@ Antibiotic, Backbone, Chassis, + Ingredient, + Medium, Part, Promoter, RestrictionEnzyme, competent, + poured, ) -from lab.units import C, minutes +from lab.units import C, L, cfu, g, minutes, ug module = lab.Module("golden_gate.designs.inventory", doc=__doc__) @@ -129,6 +132,7 @@ # 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, @@ -138,6 +142,7 @@ 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, @@ -148,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 fa5ff4fa..d16d4922 100644 --- a/crates/lab-python/tests/programs/reporter/workflow.py +++ b/crates/lab-python/tests/programs/reporter/workflow.py @@ -1,9 +1,9 @@ """Assemble the reporter, transform it, and plate what recovers.""" import lab -from lab import Material, Plate -from lab.bio.designs import Antibiotic, Chassis, Strain, competent -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 @@ -11,11 +11,17 @@ 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.", @@ -26,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_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/language/decisions/0052-material-states-are-declared-facets.md b/docs/language/decisions/0052-material-states-are-declared-facets.md index d8b8825d..bea84e91 100644 --- a/docs/language/decisions/0052-material-states-are-declared-facets.md +++ b/docs/language/decisions/0052-material-states-are-declared-facets.md @@ -2,12 +2,9 @@ ## Status -Accepted, partially implemented. 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). - -Facets are declared, exported, and narrow a material. A facet state's declared fields are carried but -never required at a declaration, so a state cannot yet stand as an acceptance criterion. +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 @@ -81,10 +78,16 @@ happened to do. ## Consequences -- `Culture`, `Clone`, and `Plate` stop being types, once a cultivation facet exists to replace them. - A culture becomes `Material` in a state, so it knows its organism from its type argument - and its medium from the state's fields. They remain fieldless prelude types until then, and the - 27 sites that name them span the Python SDK and the OT-2 adapter as well as the compiler. +- `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. diff --git a/docs/language/decisions/0053-quantities-carry-dimensions-and-compose.md b/docs/language/decisions/0053-quantities-carry-dimensions-and-compose.md index 118a9101..0d319a62 100644 --- a/docs/language/decisions/0053-quantities-carry-dimensions-and-compose.md +++ b/docs/language/decisions/0053-quantities-carry-dimensions-and-compose.md @@ -2,11 +2,7 @@ ## Status -Accepted, partially implemented. Amends -[0025: A quantity type names the unit it is measured in](0025-quantity-types.md). - -Unit safety in arithmetic and comparison holds, and `Mass` and `MassConcentration` exist as canonical -Procedure quantities. Dimensions, written conversion, and dimensional arithmetic do not. +Accepted. Amends [0025: A quantity type names the unit it is measured in](0025-quantity-types.md). ## Context @@ -15,9 +11,8 @@ Procedure quantities. Dimensions, written conversion, and dimensional arithmetic 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; -a transformation buffer is 50 mM calcium chloride. One component list cannot hold both, because a -field pins one unit and a recipe is not written in one unit. +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 @@ -35,8 +30,11 @@ 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. A recipe holds `10 g/L` and `50 mM` in one list because the field asks for a -concentration rather than for millimolar. +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. diff --git a/docs/language/specimens/dependency-build.lab b/docs/language/specimens/dependency-build.lab index e5b76116..ee6f797e 100644 --- a/docs/language/specimens/dependency-build.lab +++ b/docs/language/specimens/dependency-build.lab @@ -20,7 +20,11 @@ buy: restriction_enzyme BsmBI 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") @@ -65,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 054eef33..963eedf4 100644 --- a/docs/language/specimens/inventory-plasmid.lab +++ b/docs/language/specimens/inventory-plasmid.lab @@ -18,7 +18,11 @@ buy: restriction_enzyme BsaI chassis DH5alpha: competence = competent + efficiency = 1e9 cfu/ug antibiotic chloramphenicol + medium LB_chloramphenicol_agar: + pouring = poured + selection = chloramphenicol reporter_sequence: DNA = dna("ACGTACGT") @@ -48,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 48a5d5f6..ee8baace 100644 --- a/docs/language/specimens/plasmid-build.lab +++ b/docs/language/specimens/plasmid-build.lab @@ -6,7 +6,11 @@ use std.lab.plasmid buy: 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 @@ -33,7 +37,7 @@ record PlateObservation is Evidential: elapsed: Duration record ColonyGrowth: - plate: Material + plate: Material observations: List case Ready: @@ -49,7 +53,7 @@ record SequenceCheck: case Mismatch case Inconclusive -workflow await_colonies(plate: Material) -> ColonyGrowth: +workflow await_colonies(plate: Material) -> ColonyGrowth: state observations: List = [] @@ -86,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 diff --git a/docs/language/syntax.md b/docs/language/syntax.md index 1984157b..f19493c8 100644 --- a/docs/language/syntax.md +++ b/docs/language/syntax.md @@ -76,6 +76,18 @@ workflow transform_into(cells: Material) -> 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: 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 75470d1a..af982fa3 100644 --- a/examples/golden-gate-extended/src/designs/inventory.lab +++ b/examples/golden-gate-extended/src/designs/inventory.lab @@ -43,6 +43,7 @@ buy: 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 @@ -51,6 +52,7 @@ 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 @@ -58,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/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 7d1d392d..e238a310 100644 --- a/examples/golden-gate-python/golden_gate/designs/inventory.py +++ b/examples/golden-gate-python/golden_gate/designs/inventory.py @@ -12,7 +12,7 @@ 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") @@ -87,6 +87,7 @@ ) 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, @@ -95,6 +96,7 @@ ) 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, @@ -102,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 82a7e9ff..aa9de198 100644 --- a/examples/golden-gate/src/designs/inventory.lab +++ b/examples/golden-gate/src/designs/inventory.lab @@ -72,6 +72,7 @@ buy: 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 @@ -80,6 +81,7 @@ 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 @@ -87,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 From 9a765daa3126e25e0ff85845e0bd86aeca181162 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 3 Sep 2026 23:35:09 -0600 Subject: [PATCH 04/14] Realize any declared artifact, which makes media makeable `realize` was typed to `Plasmid` at the contract, hardcoded `PlasmidProduct` in the IR op, and refused every other kind at lowering, so the one verb wired end to end could make exactly one kind of thing. Realizing is making what a declaration describes, whatever that is: the declaration carries the recipe, and how making it runs is a Method's business. The contract takes any declared design and yields its material. A kind with no lowering contract of its own lowers as a made artifact: a design op stating its name and kind, and a realization whose product state is named for what is being realized, so a medium arrives as a MediumProduct the way a plasmid arrives as a PlasmidProduct. All three realization methods say their product state is the one the Intent asked for, which keeps the registry's one signature per Intent; the Golden Gate candidates drop out of any realization that states no assembly recipe, so making a medium refines to manual realization alone. A Golden Gate recipe still implies a plasmid, and the verifier says so. With that, the protocol this work set out to express compiles through to the planning problem, in both languages: build medium LB_broth: 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 `lower_type` was missing `Decimal` from its builtin dispatch, which surfaced as "expects Decimal, found Decimal" the first time a schema field was typed with it. --- crates/lab-compiler/src/design/ir.rs | 55 +++++++++ crates/lab-compiler/src/method/standard.rs | 4 +- crates/lab-compiler/src/program/lowering.rs | 41 +++++-- crates/lab-compiler/src/program/mod.rs | 104 +++++++++++++++--- crates/lab-compiler/src/workflow/ir.rs | 24 +++- .../lab-language/src/checker/declarations.rs | 1 + .../src/standard_library/bio/build.rs | 8 +- 7 files changed, 206 insertions(+), 31 deletions(-) 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/standard.rs b/crates/lab-compiler/src/method/standard.rs index 82c654a7..b6742dac 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( diff --git a/crates/lab-compiler/src/program/lowering.rs b/crates/lab-compiler/src/program/lowering.rs index 70bb66e2..29055449 100644 --- a/crates/lab-compiler/src/program/lowering.rs +++ b/crates/lab-compiler/src/program/lowering.rs @@ -91,10 +91,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, @@ -160,6 +156,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 { @@ -167,6 +170,7 @@ impl BuildArtifactIntent { match self { Self::Plasmid(intent) => &intent.name, Self::Strain(intent) => &intent.name, + Self::Made(intent) => &intent.name, } } @@ -174,6 +178,7 @@ impl BuildArtifactIntent { match self { Self::Plasmid(intent) => &intent.dependencies, Self::Strain(intent) => &intent.dependencies, + Self::Made(intent) => &intent.dependencies, } } @@ -181,10 +186,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, @@ -312,6 +331,7 @@ pub(crate) fn lower_build_intent( let CheckedDeclaration::Artifact { artifact, name, + produces, properties, .. } = declaration @@ -322,6 +342,7 @@ pub(crate) fn lower_build_intent( module.module.as_str(), artifact.as_str(), name, + produces, properties, &context, )?); @@ -343,6 +364,7 @@ fn lower_artifact( module: &str, kind: &str, name: &str, + produces: &lab_language::CheckedType, properties: &[lab_language::CheckedProperty], context: &BuildLoweringContext<'_>, ) -> Result { @@ -501,12 +523,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(), + })), } } diff --git a/crates/lab-compiler/src/program/mod.rs b/crates/lab-compiler/src/program/mod.rs index 42d5608b..c2b57d22 100644 --- a/crates/lab-compiler/src/program/mod.rs +++ b/crates/lab-compiler/src/program/mod.rs @@ -21,7 +21,9 @@ 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}; @@ -146,6 +148,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,23 +349,37 @@ 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); @@ -726,6 +752,56 @@ workflow build_second() -> Material: /// 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}" + ); + } + #[test] fn provisioning_yields_the_state_of_the_thing_fetched() { const SOURCE: &str = r#"use std.bio.designs diff --git a/crates/lab-compiler/src/workflow/ir.rs b/crates/lab-compiler/src/workflow/ir.rs index d74abb85..61791b51 100644 --- a/crates/lab-compiler/src/workflow/ir.rs +++ b/crates/lab-compiler/src/workflow/ir.rs @@ -85,17 +85,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::state(ctx, "PlasmidProduct")], + vec![MaterialType::state(ctx, state)], vec![design], vec![], 0, @@ -119,7 +124,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())); @@ -184,9 +189,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), - "PlasmidProduct", + "workflow.realize product", self.loc(ctx), ctx, ) diff --git a/crates/lab-language/src/checker/declarations.rs b/crates/lab-language/src/checker/declarations.rs index bab14b3d..8cfc1e57 100644 --- a/crates/lab-language/src/checker/declarations.rs +++ b/crates/lab-language/src/checker/declarations.rs @@ -1668,6 +1668,7 @@ Temperature, and Count", } 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/standard_library/bio/build.rs b/crates/lab-language/src/standard_library/bio/build.rs index b69e4d90..c6bc8a63 100644 --- a/crates/lab-language/src/standard_library/bio/build.rs +++ b/crates/lab-language/src/standard_library/bio/build.rs @@ -15,9 +15,13 @@ pub(in crate::standard_library::bio) fn module() -> StandardModule { operation: "std.bio.build.realize", phrase: vec![ 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 { name: "design", - r#type: concrete(named("Plasmid")), + r#type: ContractType::AnyValue, mode: OwnershipMode::Copy, }, // A realization with no artifact inputs writes nothing, so leaving @@ -36,7 +40,7 @@ pub(in crate::standard_library::bio) fn module() -> StandardModule { inert: &[], results: vec![ResultSpec { name: "product", - r#type: concrete(material(named("Plasmid"))), + r#type: ContractType::MaterialOf("design"), lineage: Lineage::Continues, }], }; From dbd51cd51d428c5c28b7fa6ac3360331844d73b7 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Fri, 4 Sep 2026 10:37:37 -0600 Subject: [PATCH 05/14] Give action contracts owned strings The contract a durable action is checked against was built entirely from `&'static str`: every phrase word, operand name, and unit was a literal, which suited definitions written into the compiler and nothing else. A verb a package declares in source produces owned strings at runtime, so the representation has to own them too. The phrase parts, contract type references, result names, operation identity, and the inert-operand list are `String` now, with ergonomic constructors so the standard-library definitions read as they did. Nothing changes about what is checked; this is the representation the `action` declaration form needs to produce. --- .../src/checker/action_contract.rs | 14 +- crates/lab-language/src/provenance.rs | 7 +- .../src/standard_library/bio/build.rs | 28 ++- .../src/standard_library/catalog.rs | 28 +-- .../src/standard_library/contract.rs | 111 +++++++---- .../src/standard_library/lab/plasmid.rs | 181 ++++++++---------- 6 files changed, 195 insertions(+), 174 deletions(-) diff --git a/crates/lab-language/src/checker/action_contract.rs b/crates/lab-language/src/checker/action_contract.rs index 43e38357..fd3656ac 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()); @@ -209,7 +209,7 @@ impl Checker { ), ) })?; - if !units.contains(unit) { + if !units.iter().any(|allowed| allowed == unit) { return Err(SemanticError::new( effect.span, format!( @@ -248,7 +248,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 @@ -278,14 +278,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/provenance.rs b/crates/lab-language/src/provenance.rs index de0297ff..69e58363 100644 --- a/crates/lab-language/src/provenance.rs +++ b/crates/lab-language/src/provenance.rs @@ -107,7 +107,7 @@ type LineageTable = BTreeMap; pub(crate) struct ActionLineage { results: Vec, /// Operands whose lineage no result carries on. - inert: &'static [&'static str], + inert: Vec, } /// What every workflow in a module knows about where its materials came from, @@ -139,7 +139,7 @@ pub(crate) fn lineage_table(library: &StandardLibrary) -> LineageTable { action.operation.to_owned(), ActionLineage { results, - inert: action.inert, + inert: action.inert.clone(), }, ) }) @@ -233,7 +233,8 @@ impl Analyzer<'_> { continue; } // What an organism sits on is not part of the organism. - if declared.is_some_and(|action| action.inert.contains(&argument.name.as_str())) { + if declared.is_some_and(|action| action.inert.iter().any(|name| name == &argument.name)) + { continue; } match self.map.of(&argument.value) { diff --git a/crates/lab-language/src/standard_library/bio/build.rs b/crates/lab-language/src/standard_library/bio/build.rs index c6bc8a63..61bdee44 100644 --- a/crates/lab-language/src/standard_library/bio/build.rs +++ b/crates/lab-language/src/standard_library/bio/build.rs @@ -12,35 +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::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 { - name: "design", - r#type: ContractType::AnyValue, - mode: OwnershipMode::Copy, - }, + 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: &[], + inert: Vec::new(), results: vec![ResultSpec { - name: "product", - r#type: ContractType::MaterialOf("design"), + 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 c5421650..1d862f97 100644 --- a/crates/lab-language/src/standard_library/catalog.rs +++ b/crates/lab-language/src/standard_library/catalog.rs @@ -407,7 +407,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 +423,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,13 +715,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, - }], - inert: &[], + 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 3f0fc725..0397030e 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,14 +110,14 @@ 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. @@ -91,11 +126,11 @@ pub(crate) struct ActionContractSpec { /// 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: &'static [&'static str], + 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, @@ -124,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()); } @@ -177,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 @@ -186,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}'", @@ -211,28 +246,24 @@ mod tests { fn contract(phrase: Vec) -> ActionContractSpec { ActionContractSpec { - operation: "test.action", + operation: "test.action".to_owned(), phrase, - inert: &[], + 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", ))))), @@ -240,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 @@ -252,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"); @@ -267,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 116f713b..244eeb15 100644 --- a/crates/lab-language/src/standard_library/lab/plasmid.rs +++ b/crates/lab-language/src/standard_library/lab/plasmid.rs @@ -11,17 +11,17 @@ 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, }; @@ -38,65 +38,68 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { 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"), + PhrasePart::word("capture"), + PhrasePart::word("image"), + PhrasePart::word("of"), operand("plate", concrete(plate("inoculated")), borrow), ], - inert: &[], + 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: &[], + 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: &[], + 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. - inert: &[], - 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"), + 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. @@ -109,40 +112,36 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { take, ), ], - inert: &[], + inert: Vec::new(), results: vec![ begins("strain", concrete(material(named("Strain")))), begins("culture", concrete(strain("transformed"))), ], }, ActionContractSpec { - operation: "std.lab.plasmid.recover", + operation: "std.lab.plasmid.recover".to_owned(), phrase: vec![ - PhrasePart::Word("recover"), + PhrasePart::word("recover"), operand("culture", concrete(strain("transformed")), take), - PhrasePart::Word("for"), - PhrasePart::Quantity { - name: "duration", - signed: false, - units: &["min", "h"], - }, + PhrasePart::word("for"), + PhrasePart::quantity("duration", false, &["min", "h"]), ], - inert: &[], + 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"), + PhrasePart::word("dilute"), operand("culture", concrete(strain("recovered")), take), ], - inert: &[], + 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"), + 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. @@ -151,131 +150,119 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { concrete(Ty::Union(vec![strain("recovered"), strain("diluted")])), take, ), - PhrasePart::Word("on"), + 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), ], - inert: &["medium"], + 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"), + PhrasePart::word("pick"), + PhrasePart::integer("count", false), + PhrasePart::word("isolated"), + PhrasePart::word("colonies"), + PhrasePart::word("from"), operand("plate", concrete(plate("inoculated")), borrow), ], - inert: &[], + inert: Vec::new(), results: vec![begins( "candidates", 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(strain("isolated")))), take, ), - PhrasePart::Word("against"), + PhrasePart::word("against"), operand("design", concrete(named("Plasmid")), copy), ], - inert: &[], + inert: Vec::new(), results: vec![result("screening", concrete(named("Screening")))], }, ActionContractSpec { - operation: "std.lab.plasmid.grow", + operation: "std.lab.plasmid.grow".to_owned(), phrase: vec![ - PhrasePart::Word("grow"), + PhrasePart::word("grow"), operand("clone", concrete(strain("isolated")), 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("at"), + PhrasePart::quantity("temperature", true, &["C"]), + PhrasePart::word("for"), + PhrasePart::quantity("duration", false, &["h"]), ], - inert: &[], + 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"), + PhrasePart::word("purify"), operand("culture", concrete(strain("grown")), take), ], - inert: &[], + 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: &[], + 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: &[], + 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: &[], + 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"]), ], - inert: &[], - 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: &[], + inert: Vec::new(), results: Vec::new(), }, ]; From da6a326de2b2860f91a81c2c250d9c446ec0c464 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Fri, 4 Sep 2026 10:59:05 -0600 Subject: [PATCH 06/14] Let a package declare a durable verb The six verbs that lower were Rust ops, and the dozen that only type-check were entries in a compiled table. Neither is something a laboratory can add, so the vocabulary of what a protocol does was closed in a way the vocabulary of what it works on is not. An `action` declares a verb the way a workflow writes it: the phrase with its operands in `<>`, the results after `->`, and a body that types each operand and result, gives an operand its ownership mode, and names the capability a facility must offer. It checks a workflow exactly as a bundled verb does, because it produces the same contract, and it crosses a module boundary with its phrase, types, and capability intact. This is the frontend half. A declared verb type-checks and is refused where it is misused: a wrong word, a missing operand, a measurement in the wrong unit, or no capability. What it does not yet do is lower, which is the same state the dozen contracted-but-unlowered bundled verbs are already in. The generic operation that lowers it, and the manual method derived from its capability, come next. --- crates/lab-ide/src/model.rs | 1 + crates/lab-ide/src/semantic.rs | 14 +- crates/lab-language-server/src/features.rs | 2 + crates/lab-language/src/ast.rs | 47 +++++ crates/lab-language/src/checked.rs | 49 +++++- crates/lab-language/src/checker/context.rs | 83 ++++++++- .../lab-language/src/checker/declarations.rs | 163 +++++++++++++++++- crates/lab-language/src/checker/interface.rs | 35 +++- crates/lab-language/src/checker/mod.rs | 148 +++++++++++++++- crates/lab-language/src/parser.rs | 86 +++++++++ crates/lab-language/src/render.rs | 10 ++ .../lab-language/src/semantics/interface.rs | 14 ++ crates/lab-language/src/semantics/mod.rs | 4 +- .../src/standard_library/contract.rs | 2 +- .../src/standard_library/manifest.rs | 26 ++- .../lab-language/src/standard_library/mod.rs | 2 +- 16 files changed, 659 insertions(+), 27 deletions(-) diff --git a/crates/lab-ide/src/model.rs b/crates/lab-ide/src/model.rs index dae8b943..2f4a28cc 100644 --- a/crates/lab-ide/src/model.rs +++ b/crates/lab-ide/src/model.rs @@ -7,6 +7,7 @@ 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 2888bdfb..82da4d27 100644 --- a/crates/lab-ide/src/semantic.rs +++ b/crates/lab-ide/src/semantic.rs @@ -5,9 +5,10 @@ use lab_language::{Span, ast}; use crate::{DocumentSymbol, SemanticToken, SemanticTokenKind, SymbolKind}; pub(crate) const KEYWORDS: &[&str] = &[ - "use", "role", "facet", "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", + "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)> { @@ -15,6 +16,7 @@ pub(crate) fn declaration(item: &ast::Item) -> Option<(&str, SymbolKind, Span)> 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)), @@ -32,6 +34,7 @@ pub(crate) fn documentation(item: &ast::Item) -> Option<&str> { 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(), @@ -220,6 +223,11 @@ fn semantic_names(module: Option<&ast::Module>) -> SemanticNames { 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 07fbc40c..9411d1af 100644 --- a/crates/lab-language-server/src/features.rs +++ b/crates/lab-language-server/src/features.rs @@ -305,6 +305,8 @@ fn symbol_kind(kind: SymbolKind) -> lsp::SymbolKind { // 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 d32e9c9a..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)] @@ -47,6 +48,7 @@ pub enum Item { Use(UseDecl), Role(RoleDecl), Facet(FacetDecl), + Action(ActionDecl), ArtifactKind(ArtifactKindDecl), Circuit(CircuitDecl), Artifact(ArtifactDecl), @@ -61,6 +63,7 @@ impl Item { 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, @@ -97,6 +100,50 @@ 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. /// diff --git a/crates/lab-language/src/checked.rs b/crates/lab-language/src/checked.rs index af3468b2..d728c411 100644 --- a/crates/lab-language/src/checked.rs +++ b/crates/lab-language/src/checked.rs @@ -21,13 +21,14 @@ use crate::semantics::{DefinitionId, ModuleId, ModuleInterface}; /// Intent operation identities, typed values, ownership, and exact workflow /// 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, and a type argument may be narrowed -/// to one of those states. +/// 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.v10"; +pub const PORTABLE_MODULE_SCHEMA_VERSION: &str = "lab.portable-module.v11"; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CheckedModule { @@ -79,6 +80,25 @@ pub enum CheckedDeclaration { #[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 @@ -322,6 +342,29 @@ 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 { diff --git a/crates/lab-language/src/checker/context.rs b/crates/lab-language/src/checker/context.rs index 8426822a..f8e630a9 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,62 @@ 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()]), + 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)] @@ -110,6 +168,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, @@ -165,6 +230,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(), @@ -478,7 +544,14 @@ 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)); + } + } + 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 8cfc1e57..4531236e 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, FacetDecl, FieldDecl, Item, Module, - Path, Provenance, TypeArgument, TypeExpr, Unit, WorkflowOutputs, instance_word, + 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, FacetSignature, FacetState, 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. @@ -254,6 +259,7 @@ impl Checker { 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), @@ -322,6 +328,15 @@ impl Checker { } } + // 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) => { @@ -348,8 +363,8 @@ impl Checker { }, ); } - // Both are collected in passes of their own. - Item::Role(_) | Item::Facet(_) => {} + // 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 @@ -691,6 +706,138 @@ impl Checker { }) } + /// 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])), + 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(); diff --git a/crates/lab-language/src/checker/interface.rs b/crates/lab-language/src/checker/interface.rs index 20284421..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, FacetSurface, ModuleExport, ModuleId, - ModuleInterface, TypeParameters, + ActionSurface, CallableSignature, DefinitionId, ExportKind, FacetSurface, ModuleExport, + ModuleId, ModuleInterface, TypeParameters, }; pub(super) fn build_interface( @@ -34,6 +34,7 @@ pub(super) fn build_interface( term: None, schema: None, facet: None, + action: None, parameters: TypeParameters::default(), documentation: documentation.clone().unwrap_or_default(), }, @@ -75,6 +76,36 @@ 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, diff --git a/crates/lab-language/src/checker/mod.rs b/crates/lab-language/src/checker/mod.rs index 5599f295..071a94e6 100644 --- a/crates/lab-language/src/checker/mod.rs +++ b/crates/lab-language/src/checker/mod.rs @@ -115,6 +115,21 @@ impl Checker { .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 @@ -2008,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.v10"); + 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"); @@ -2724,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( diff --git a/crates/lab-language/src/parser.rs b/crates/lab-language/src/parser.rs index 2328aee8..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}; @@ -65,6 +66,10 @@ impl<'a> Parser<'a> { 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; @@ -225,6 +230,87 @@ impl<'a> Parser<'a> { }) } + /// `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") { diff --git a/crates/lab-language/src/render.rs b/crates/lab-language/src/render.rs index a683c4bc..151bdbf6 100644 --- a/crates/lab-language/src/render.rs +++ b/crates/lab-language/src/render.rs @@ -32,6 +32,16 @@ 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, diff --git a/crates/lab-language/src/semantics/interface.rs b/crates/lab-language/src/semantics/interface.rs index 3f0243d5..c35317db 100644 --- a/crates/lab-language/src/semantics/interface.rs +++ b/crates/lab-language/src/semantics/interface.rs @@ -49,6 +49,10 @@ pub struct ModuleExport { /// 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 @@ -68,6 +72,16 @@ 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 { diff --git a/crates/lab-language/src/semantics/mod.rs b/crates/lab-language/src/semantics/mod.rs index f101f1f8..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, FacetSurface, ModuleExport, ModuleInterface, - SemanticEnvironment, TypeParameters, + ActionSurface, ArtifactSchema, CallableSignature, ExportKind, FacetSurface, ModuleExport, + ModuleInterface, SemanticEnvironment, TypeParameters, }; diff --git a/crates/lab-language/src/standard_library/contract.rs b/crates/lab-language/src/standard_library/contract.rs index 0397030e..3e9c10ce 100644 --- a/crates/lab-language/src/standard_library/contract.rs +++ b/crates/lab-language/src/standard_library/contract.rs @@ -137,7 +137,7 @@ impl ActionContractSpec { } } - 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())?; diff --git a/crates/lab-language/src/standard_library/manifest.rs b/crates/lab-language/src/standard_library/manifest.rs index 21a708c5..f4d4c755 100644 --- a/crates/lab-language/src/standard_library/manifest.rs +++ b/crates/lab-language/src/standard_library/manifest.rs @@ -334,6 +334,30 @@ fn authored_export(name: &str, export: &ModuleExport) -> Option { .collect(), }) } - ExportKind::Action => None, + 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) => operand.clone(), + }) + .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() From b5e7fe8ba3a1dc1833cb7729a145717ec7b41a85 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Fri, 4 Sep 2026 11:26:19 -0600 Subject: [PATCH 07/14] Refine any declared verb through a derived manual method The `action` declaration form gave a package the vocabulary to write a durable verb and have a workflow check against it, but every verb still needed a Rust lowering and a hand-written method to reach a plan. A declared verb had nowhere to go once it left the checker. A workflow.perform Intent carries everything a declared verb needs: the operation it refines, the capability that runs it, the material operands it consumes, the scalar parameters it carries as the text they were written as, and the state each result arrives in. Lowering routes any verb that is not one of the six with a bespoke lowering into a perform, reading its operands, parameters, and result states from the checked call. Refinement recognizes a perform and derives one manual bench method for it on the spot: the operands become inputs in the states the IR gives them, the parameters ride through, the capability becomes the requirement, and each result is requested from the Intent so it arrives in the state the verb declared. No registry entry, no per-verb Rust. With that, a package can declare a verb the compiler has never seen and a workflow that performs it lowers to a perform Intent and refines to a derived- method, through to the planning problem. --- crates/lab-compiler/src/method/mod.rs | 1 + crates/lab-compiler/src/method/refinement.rs | 70 +++++++- crates/lab-compiler/src/method/standard.rs | 70 ++++++++ crates/lab-compiler/src/procedure/ir.rs | 7 + crates/lab-compiler/src/program/lowering.rs | 115 ++++++++++++- crates/lab-compiler/src/program/mod.rs | 82 +++++++++- crates/lab-compiler/src/workflow/ir.rs | 160 +++++++++++++++++++ 7 files changed, 496 insertions(+), 9 deletions(-) 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 b3b5e04b..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( @@ -92,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(), @@ -215,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() { @@ -446,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, diff --git a/crates/lab-compiler/src/method/standard.rs b/crates/lab-compiler/src/method/standard.rs index b6742dac..c10a43a8 100644 --- a/crates/lab-compiler/src/method/standard.rs +++ b/crates/lab-compiler/src/method/standard.rs @@ -661,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), 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/program/lowering.rs b/crates/lab-compiler/src/program/lowering.rs index 29055449..038829fa 100644 --- a/crates/lab-compiler/src/program/lowering.rs +++ b/crates/lab-compiler/src/program/lowering.rs @@ -43,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)] @@ -652,6 +670,7 @@ fn realization_flows( catalog_types: &BTreeMap, selections: &BTreeMap, ) -> Result, SourceLoweringError> { + let capabilities = action_capabilities(modules); let mut result = BTreeMap::new(); for declaration in declarations(modules) { let CheckedDeclaration::Workflow { body, .. } = declaration else { @@ -777,11 +796,11 @@ fn realization_flows( }, }); } - 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, &capabilities)?); } } } @@ -850,6 +869,92 @@ 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, + capabilities: &BTreeMap, +) -> Result { + let invalid = || SourceLoweringError::InvalidActionResults { + artifact: String::new(), + operation: action.operation.clone(), + }; + let capability = capabilities + .get(&action.operation) + .cloned() + .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 capability each declared verb needs, keyed by the operation it refines. +fn action_capabilities(modules: &[&CheckedModule]) -> BTreeMap { + declarations(modules) + .filter_map(|declaration| match declaration { + CheckedDeclaration::Action { + operation, + capability, + .. + } => Some((operation.clone(), capability.clone())), + _ => None, + }) + .collect() +} + +/// 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 diff --git a/crates/lab-compiler/src/program/mod.rs b/crates/lab-compiler/src/program/mod.rs index c2b57d22..a778b509 100644 --- a/crates/lab-compiler/src/program/mod.rs +++ b/crates/lab-compiler/src/program/mod.rs @@ -26,7 +26,9 @@ use crate::design::ir::{ }; 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; @@ -477,6 +479,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(()) @@ -802,6 +833,55 @@ workflow make_LB() -> Material: ); } + /// 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 diff --git a/crates/lab-compiler/src/workflow/ir.rs b/crates/lab-compiler/src/workflow/ir.rs index 61791b51..c5491528 100644 --- a/crates/lab-compiler/src/workflow/ir.rs +++ b/crates/lab-compiler/src/workflow/ir.rs @@ -6,6 +6,7 @@ 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}; @@ -689,6 +690,165 @@ impl Verify for PlateOp { } } +/// 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), + )?; + 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, From 2b97f18457fc8f430260c8ab07e9b686fa00d2c6 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Fri, 4 Sep 2026 12:09:54 -0600 Subject: [PATCH 08/14] Make competent cells with a declared-verb vocabulary Growing cells up, chilling them, spinning them into a pellet, and washing them into cold buffer are everyday bench steps the compiler had no words for. They are not assembly or transformation, so none of them fit the six bundled verbs, and until a package could declare a verb there was nowhere to put them. std.lab.competence declares them as actions: grow, chill, centrifuge, and resuspend, over a Growth facet that carries a chassis from dormant through growing to pelleted, with the existing Competence facet's competent as the state the wash leaves it in. grow reaches a target optical density read at either wavelength, written `to 0.40 OD600` or `to 0.40 OD700`; the two are distinct units that do not convert, so the operand is a union of the two measurements and the protocol writes whichever its plate reader reports. A buffer and a medium both play a new Solution role, so resuspend pours either. Three things the vocabulary needed the frontend to learn. A measured operand now carries a decimal magnitude, because an optical density is 0.40 and not 0. A union of measurements is one operand read in any of its units, which is what the OD600-or-OD700 endpoint is. And a declared verb carries the capability it needs on the resolved call, so the derived method requires it whether the verb is declared here or imported. The bundled plasmid-prep grow, which cultures a clone for a miniprep, is now `culture`, so the two no longer collide on a name. The whole protocol checks, lowers to perform Intents, and refines to derived manual methods, in Lab as a compiler acceptance test and in Python as an SDK one, through to the planning problem. --- crates/lab-compiler/src/program/lowering.rs | 27 +--- crates/lab-compiler/src/program/mod.rs | 61 ++++++++ crates/lab-language/src/checked.rs | 8 ++ .../src/checker/action_contract.rs | 17 ++- crates/lab-language/src/checker/context.rs | 32 +++++ .../lab-language/src/checker/declarations.rs | 19 +++ crates/lab-language/src/checker/workflow.rs | 13 +- .../standard_library/authored/competence.lab | 92 ++++++++++++ .../src/standard_library/catalog.rs | 4 + .../src/standard_library/lab/plasmid.rs | 4 +- .../src/standard_library/manifest.rs | 4 +- .../src/standard_library/prelude.rs | 9 +- crates/lab-python/pyproject.toml | 2 +- crates/lab-python/python/lab/__init__.py | 4 +- crates/lab-python/python/lab/_prelude.py | 15 ++ crates/lab-python/python/lab/competence.py | 132 ++++++++++++++++++ crates/lab-python/python/lab/plasmid.py | 8 +- .../lab-python/tests/programs/competence.py | 35 +++++ crates/lab-python/tests/test_competence.py | 36 +++++ docs/language/specimens/plasmid-build.lab | 4 +- 20 files changed, 487 insertions(+), 39 deletions(-) create mode 100644 crates/lab-language/src/standard_library/authored/competence.lab create mode 100644 crates/lab-python/python/lab/competence.py create mode 100644 crates/lab-python/tests/programs/competence.py create mode 100644 crates/lab-python/tests/test_competence.py diff --git a/crates/lab-compiler/src/program/lowering.rs b/crates/lab-compiler/src/program/lowering.rs index 038829fa..e74448f3 100644 --- a/crates/lab-compiler/src/program/lowering.rs +++ b/crates/lab-compiler/src/program/lowering.rs @@ -670,7 +670,6 @@ fn realization_flows( catalog_types: &BTreeMap, selections: &BTreeMap, ) -> Result, SourceLoweringError> { - let capabilities = action_capabilities(modules); let mut result = BTreeMap::new(); for declaration in declarations(modules) { let CheckedDeclaration::Workflow { body, .. } = declaration else { @@ -800,7 +799,7 @@ fn realization_flows( // bespoke lowering is one a package declared. Its operands, // parameters, and result states come from the checked call. _ => { - actions.push(perform_intent(action, &capabilities)?); + actions.push(perform_intent(action)?); } } } @@ -874,18 +873,12 @@ fn dependency_names( /// 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, - capabilities: &BTreeMap, -) -> Result { +fn perform_intent(action: &ResolvedAction) -> Result { let invalid = || SourceLoweringError::InvalidActionResults { artifact: String::new(), operation: action.operation.clone(), }; - let capability = capabilities - .get(&action.operation) - .cloned() - .ok_or_else(invalid)?; + let capability = action.capability.clone().ok_or_else(invalid)?; let mut operands = Vec::new(); let mut parameters = Vec::new(); for argument in &action.arguments { @@ -921,20 +914,6 @@ fn perform_intent( }) } -/// The capability each declared verb needs, keyed by the operation it refines. -fn action_capabilities(modules: &[&CheckedModule]) -> BTreeMap { - declarations(modules) - .filter_map(|declaration| match declaration { - CheckedDeclaration::Action { - operation, - capability, - .. - } => Some((operation.clone(), capability.clone())), - _ => None, - }) - .collect() -} - /// The workflow material state a result type stands for. /// /// A material narrowed to a state arrives in that state; an unnarrowed one diff --git a/crates/lab-compiler/src/program/mod.rs b/crates/lab-compiler/src/program/mod.rs index a778b509..38fda162 100644 --- a/crates/lab-compiler/src/program/mod.rs +++ b/crates/lab-compiler/src/program/mod.rs @@ -1057,6 +1057,67 @@ 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" + ); + } + } + #[test] fn standard_methods_replace_every_workflow_op_with_verified_alternatives() { let checked = compile_module( diff --git a/crates/lab-language/src/checked.rs b/crates/lab-language/src/checked.rs index d728c411..34526f8f 100644 --- a/crates/lab-language/src/checked.rs +++ b/crates/lab-language/src/checked.rs @@ -506,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 fd3656ac..ac9e7534 100644 --- a/crates/lab-language/src/checker/action_contract.rs +++ b/crates/lab-language/src/checker/action_contract.rs @@ -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, @@ -263,6 +277,7 @@ impl Checker { ResolvedAction { operation: contract.operation.to_owned(), callee: None, + capability: None, arguments, results: checked_results, }, diff --git a/crates/lab-language/src/checker/context.rs b/crates/lab-language/src/checker/context.rs index f8e630a9..c8bd6eb6 100644 --- a/crates/lab-language/src/checker/context.rs +++ b/crates/lab-language/src/checker/context.rs @@ -90,6 +90,25 @@ fn action_contract_from_surface(surface: &ActionSurface) -> ActionContractSpec { 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), } @@ -549,6 +568,19 @@ impl SemanticContext { 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 => { diff --git a/crates/lab-language/src/checker/declarations.rs b/crates/lab-language/src/checker/declarations.rs index 4531236e..433f9311 100644 --- a/crates/lab-language/src/checker/declarations.rs +++ b/crates/lab-language/src/checker/declarations.rs @@ -821,6 +821,25 @@ impl Checker { ) -> 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, 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/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/catalog.rs b/crates/lab-language/src/standard_library/catalog.rs index 1d862f97..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(); diff --git a/crates/lab-language/src/standard_library/lab/plasmid.rs b/crates/lab-language/src/standard_library/lab/plasmid.rs index 244eeb15..ed868b6e 100644 --- a/crates/lab-language/src/standard_library/lab/plasmid.rs +++ b/crates/lab-language/src/standard_library/lab/plasmid.rs @@ -191,9 +191,9 @@ pub(in crate::standard_library::lab) fn module() -> StandardModule { results: vec![result("screening", concrete(named("Screening")))], }, ActionContractSpec { - operation: "std.lab.plasmid.grow".to_owned(), + operation: "std.lab.plasmid.culture".to_owned(), phrase: vec![ - PhrasePart::word("grow"), + PhrasePart::word("culture"), operand("clone", concrete(strain("isolated")), take), PhrasePart::word("at"), PhrasePart::quantity("temperature", true, &["C"]), diff --git a/crates/lab-language/src/standard_library/manifest.rs b/crates/lab-language/src/standard_library/manifest.rs index f4d4c755..c1ce1e84 100644 --- a/crates/lab-language/src/standard_library/manifest.rs +++ b/crates/lab-language/src/standard_library/manifest.rs @@ -344,7 +344,9 @@ fn authored_export(name: &str, export: &ModuleExport) -> Option { .iter() .map(|token| match token { crate::checked::CheckedPhraseToken::Word(word) => word.clone(), - crate::checked::CheckedPhraseToken::Hole(operand) => operand.clone(), + crate::checked::CheckedPhraseToken::Hole(operand) => { + format!("<{operand}>") + } }) .collect(), optional: Vec::new(), diff --git a/crates/lab-language/src/standard_library/prelude.rs b/crates/lab-language/src/standard_library/prelude.rs index 874654d9..91848829 100644 --- a/crates/lab-language/src/standard_library/prelude.rs +++ b/crates/lab-language/src/standard_library/prelude.rs @@ -11,6 +11,9 @@ 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), @@ -33,7 +36,9 @@ pub(in crate::standard_library) fn modules() -> Vec { TypeSpec::nominal("Image"), TypeSpec::nominal("List").parameters(1), TypeSpec::nominal("Material").parameters(1), - TypeSpec::nominal("Medium").documented("What an organism is grown in or on."), + TypeSpec::nominal("Medium") + .implements(["Solution"]) + .documented("What an organism is grown in or on."), TypeSpec::nominal("Part"), TypeSpec::nominal("Plasmid") .with_fields([ @@ -54,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-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/_prelude.py b/crates/lab-python/python/lab/_prelude.py index 68978db4..062ad43d 100644 --- a/crates/lab-python/python/lab/_prelude.py +++ b/crates/lab-python/python/lab/_prelude.py @@ -19,6 +19,7 @@ "Accepted", "Antibiotic", "Backbone", + "Buffer", "Chassis", "Circuit", "CloneSet", @@ -43,6 +44,7 @@ "RestrictionEnzyme", "Screening", "Signal", + "Solution", "Strain", "Topology", "WorkflowContext", @@ -72,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__ = () @@ -196,6 +204,13 @@ class Signal(LabRole): __lab_uses__ = () +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.""" 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 30da9b70..b91f5fb5 100644 --- a/crates/lab-python/python/lab/plasmid.py +++ b/crates/lab-python/python/lab/plasmid.py @@ -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/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/docs/language/specimens/plasmid-build.lab b/docs/language/specimens/plasmid-build.lab index ee8baace..4566c9ce 100644 --- a/docs/language/specimens/plasmid-build.lab +++ b/docs/language/specimens/plasmid-build.lab @@ -112,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 From 7e62a067c559b75a18e041585a3aeb31ff64ff3d Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Fri, 4 Sep 2026 16:25:41 -0600 Subject: [PATCH 09/14] Typeset a run sheet for a 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 a plan whose steps are all manual produced a reviewed plan.execution.json and nothing a person could print. For a human-operated facility that is every plan, and the bench was left reading JSON. lab plan and lab build now typeset an operator run sheet for the plan's manual steps: one document for the whole protocol, each step stating the operation it performs, the asset it uses, and the parameters it runs at, in the order the plan performs them. The values read the way the workflow wrote them, so a competent-cell prep prints "37 C", "0.4 OD600", and "4000 rcf". lab-adapters owns the rendering beside the instrument documents, lab-facility projects the allocated manual steps into display text, and the CLI typesets the sheet into the plan's documents directory beside its Typst source and style sheet, listed under the same Documents output as the adapter-emitted manuals. --- crates/lab-adapters/src/backend/mod.rs | 1 + crates/lab-adapters/src/backend/run_sheet.rs | 105 ++++++++++++++++++ crates/lab-adapters/src/lib.rs | 2 +- crates/lab-cli/src/commands.rs | 48 +++++++- .../manual-run-sheet/inventory/facility.ttl | 36 ++++++ .../tests/fixtures/manual-run-sheet/lab.toml | 10 ++ .../manual-run-sheet/src/programs/main.lab | 11 ++ crates/lab-cli/tests/pdf_output.rs | 41 +++++++ crates/lab-cli/tests/project_workflow.rs | 4 +- crates/lab-facility/src/execution.rs | 88 +++++++++++++++ crates/lab-facility/src/lib.rs | 1 + 11 files changed, 344 insertions(+), 3 deletions(-) create mode 100644 crates/lab-adapters/src/backend/run_sheet.rs create mode 100644 crates/lab-cli/tests/fixtures/manual-run-sheet/inventory/facility.ttl create mode 100644 crates/lab-cli/tests/fixtures/manual-run-sheet/lab.toml create mode 100644 crates/lab-cli/tests/fixtures/manual-run-sheet/src/programs/main.lab 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..9e709f7b 100644 --- a/crates/lab-cli/src/commands.rs +++ b/crates/lab-cli/src/commands.rs @@ -460,6 +460,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 +495,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/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/pdf_output.rs b/crates/lab-cli/tests/pdf_output.rs index 2a562f4c..88328a04 100644 --- a/crates/lab-cli/tests/pdf_output.rs +++ b/crates/lab-cli/tests/pdf_output.rs @@ -80,3 +80,44 @@ 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}" + ); +} diff --git a/crates/lab-cli/tests/project_workflow.rs b/crates/lab-cli/tests/project_workflow.rs index 2d85881f..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() 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::{ From 46764f236c2b18ce66d8633e17e59cbcbc066f31 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Fri, 4 Sep 2026 17:33:59 -0600 Subject: [PATCH 10/14] Emit a workflow's use lines in first-needed order The translator collected the modules a body's vocabulary comes from in a set, so the emitted `use` lines came out in string-hash order: the same program produced different bytes from one interpreter to the next, and a checked-in emitted module went stale by rerunning its own generator. Module.imports promises the order the modules are first needed, and now the collection keeps it, so emission is byte-stable. --- crates/lab-python/python/lab/_workflows.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/lab-python/python/lab/_workflows.py b/crates/lab-python/python/lab/_workflows.py index 1ac329b7..c5081e46 100644 --- a/crates/lab-python/python/lab/_workflows.py +++ b/crates/lab-python/python/lab/_workflows.py @@ -233,7 +233,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 +342,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 +357,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 +523,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: From 5edb21a55219dc28cb7a11e7d4dc90c9760c4c36 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Fri, 4 Sep 2026 18:08:52 -0600 Subject: [PATCH 11/14] Instantiate a build workflow once per design its callers pass A protocol is one procedure however many designs it runs for, but lowering keyed each build flow by the name of the design its workflow realized, so a workflow realizing its own parameter left every declared artifact without a flow and the same wash had to be written out once per strain. A workflow that realizes one of its parameters is now a template. Its flow is design-agnostic already, since the realize op takes the design from the artifact being built, so the call site is what names the design: each call instantiates the flow for the artifact its argument references, and an argument that is itself the caller's parameter names no design yet. With that, `prepare_competent_cells(chassis: Chassis)` is written once and builds DH5alpha, DH5beta, and Top10 from three calls. --- crates/lab-compiler/src/program/lowering.rs | 52 +++++++++++++++- crates/lab-compiler/src/program/mod.rs | 69 +++++++++++++++++++++ 2 files changed, 119 insertions(+), 2 deletions(-) diff --git a/crates/lab-compiler/src/program/lowering.rs b/crates/lab-compiler/src/program/lowering.rs index e74448f3..3db06bb3 100644 --- a/crates/lab-compiler/src/program/lowering.rs +++ b/crates/lab-compiler/src/program/lowering.rs @@ -671,8 +671,19 @@ fn realization_flows( selections: &BTreeMap, ) -> Result, SourceLoweringError> { let mut result = BTreeMap::new(); + // 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 declaration in declarations(modules) { - let CheckedDeclaration::Workflow { body, .. } = declaration else { + let CheckedDeclaration::Workflow { + name: workflow_name, + inputs, + body, + .. + } = declaration + else { continue; }; let Some(design) = realized_design(body) else { @@ -808,10 +819,47 @@ 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 declaration in declarations(modules) { + let CheckedDeclaration::Workflow { inputs, body, .. } = declaration else { + continue; + }; + for statement in body { + let CheckedStatement::Effect { action, .. } = statement else { + continue; + }; + 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) } diff --git a/crates/lab-compiler/src/program/mod.rs b/crates/lab-compiler/src/program/mod.rs index 38fda162..c64ac7f7 100644 --- a/crates/lab-compiler/src/program/mod.rs +++ b/crates/lab-compiler/src/program/mod.rs @@ -1118,6 +1118,75 @@ workflow prepare() -> Material: } } + /// 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( From b06734593051c025bcf3111d7187ad511420523a Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Fri, 4 Sep 2026 18:17:47 -0600 Subject: [PATCH 12/14] Import the modules a workflow's signature names The emitted use lines came only from a workflow's body, so a workflow that composes others and returns their materials emitted no import for the types its own signature names: a catalog build returning Material compiled to a module that could not see the Competence facet. The signature's annotations now contribute their modules too, after the ones the body needed, so composing workflows is enough to make a module. --- crates/lab-python/python/lab/_workflows.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/lab-python/python/lab/_workflows.py b/crates/lab-python/python/lab/_workflows.py index c5081e46..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__, From e4dc408fc8cd91edf268904f8c56fc7203aed3b9 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Fri, 4 Sep 2026 18:36:01 -0600 Subject: [PATCH 13/14] Plan one program of a workspace with lab plan --program A workspace declares more than any one run builds: a protocol catalog holds every medium and every prep, and a bench run makes exactly one of them. But a package had one build.entry, and lowering required every declared artifact to be realized by some workflow in scope, so the only way to plan anything was a single entry that built everything. Every source under src/programs/ is now a runnable program, named by its file, and `lab plan --program ` roots the build at that program's main: the artifacts are the ones main reaches through workflow calls, a declaration nothing reaches is a library entry rather than an error, and the plan and its run sheet land under .lab/plan/ so programs do not overwrite each other. Without --program the manifest entry plans exactly as before, and a package with programs but no entry says which names to pick from. `lab build --program` roots the facility outputs the same way. --- crates/lab-cli/src/commands.rs | 131 ++++++++++++++++++-- crates/lab-cli/src/main.rs | 25 +++- crates/lab-cli/tests/pdf_output.rs | 17 +++ crates/lab-compiler/src/program/lowering.rs | 128 +++++++++++++++++-- crates/lab-compiler/src/program/mod.rs | 16 ++- crates/lab-package/src/package.rs | 8 ++ crates/lab-project/src/facility.rs | 39 +++++- 7 files changed, 334 insertions(+), 30 deletions(-) diff --git a/crates/lab-cli/src/commands.rs b/crates/lab-cli/src/commands.rs index 9e709f7b..2a30a0d6 100644 --- a/crates/lab-cli/src/commands.rs +++ b/crates/lab-cli/src/commands.rs @@ -129,7 +129,12 @@ 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<()> { let project = LabProject::discover(&path) .with_context(|| format!("failed to load project from {}", path.display()))?; validate_project_inventories(&project)?; @@ -193,12 +198,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 +340,49 @@ 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<()> { 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 +402,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; 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/pdf_output.rs b/crates/lab-cli/tests/pdf_output.rs index 88328a04..e0c2bac6 100644 --- a/crates/lab-cli/tests/pdf_output.rs +++ b/crates/lab-cli/tests/pdf_output.rs @@ -120,4 +120,21 @@ fn a_manual_only_plan_typesets_a_run_sheet() { 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-compiler/src/program/lowering.rs b/crates/lab-compiler/src/program/lowering.rs index 3db06bb3..ce3274a8 100644 --- a/crates/lab-compiler/src/program/lowering.rs +++ b/crates/lab-compiler/src/program/lowering.rs @@ -330,13 +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 catalog_types = catalog_types(modules); let selections = selections(modules, &supplier_identities); - let flows = realization_flows(modules, &supplier_identities, &catalog_types, &selections)?; + // 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, @@ -356,14 +367,18 @@ pub(crate) fn lower_build_intent( 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() { @@ -372,12 +387,90 @@ 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, @@ -669,14 +762,20 @@ fn realization_flows( 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(); // 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 declaration in declarations(modules) { + for (module, declaration) in module_declarations(modules) { let CheckedDeclaration::Workflow { name: workflow_name, inputs, @@ -686,6 +785,9 @@ fn realization_flows( else { continue; }; + if !in_scope(module, workflow_name) { + continue; + } let Some(design) = realized_design(body) else { continue; }; @@ -829,14 +931,20 @@ fn realization_flows( // 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 declaration in declarations(modules) { - let CheckedDeclaration::Workflow { inputs, body, .. } = declaration else { + for (module, declaration) in module_declarations(modules) { + let CheckedDeclaration::Workflow { + name: caller, + inputs, + body, + .. + } = declaration + else { continue; }; - for statement in body { - let CheckedStatement::Effect { action, .. } = statement else { - continue; - }; + if !in_scope(module, caller) { + continue; + } + for action in effects(body) { let Some(workflow_name) = action.operation.strip_prefix("workflow.") else { continue; }; diff --git a/crates/lab-compiler/src/program/mod.rs b/crates/lab-compiler/src/program/mod.rs index c64ac7f7..abaf7c81 100644 --- a/crates/lab-compiler/src/program/mod.rs +++ b/crates/lab-compiler/src/program/mod.rs @@ -93,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, diff --git a/crates/lab-package/src/package.rs b/crates/lab-package/src/package.rs index 9a9cdfae..59e5a329 100644 --- a/crates/lab-package/src/package.rs +++ b/crates/lab-package/src/package.rs @@ -282,6 +282,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)?; From c2d68f7108356dc7d9680e4a0e7db90418e7b0d5 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Fri, 4 Sep 2026 18:56:01 -0600 Subject: [PATCH 14/14] Run a package's source generator before compiling A workspace whose Lab another frontend emits had no CLI-native way to stay fresh: the Python protocols exported their sources with a side script, and forgetting it compiled yesterday's Lab. The manifest now declares the generator the way a build script would: [build] generate = "uv --project .. run python ../scripts/export_lab.py" lab check, lab plan, and lab build run it from the package root before discovering sources, so the entry can even be a file the generator has not written yet, and a frontend-emitted workspace plans with exactly the commands a native one does. The generator's output surfaces only when it fails, alongside the command that failed. --- crates/lab-cli/src/commands.rs | 29 ++++++++++++ crates/lab-cli/tests/generated_sources.rs | 54 +++++++++++++++++++++++ crates/lab-package/src/lib.rs | 2 +- crates/lab-package/src/manifest.rs | 6 +++ crates/lab-package/src/package.rs | 16 +++++++ 5 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 crates/lab-cli/tests/generated_sources.rs diff --git a/crates/lab-cli/src/commands.rs b/crates/lab-cli/src/commands.rs index 2a30a0d6..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)?; @@ -135,6 +162,7 @@ pub(crate) fn build( 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)?; @@ -346,6 +374,7 @@ pub(crate) fn plan( 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()?; 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-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 59e5a329..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