Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
321 changes: 304 additions & 17 deletions Cargo.lock

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ members = [
"crates/lab-language-server",
"crates/lab-package",
"crates/lab-project",
"crates/lab-sbol",
"crates/lab-python",
"crates/lab-runfmt",
"crates/lab-runtime",
Expand All @@ -36,6 +37,10 @@ pyo3 = { version = "0.28.3", features = ["abi3-py311"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusb = "0.9"
# SBOL 3 terms, serialization, and validation. Designs are read from and
# written to SBOL documents, so the standard's object model is the compiler's
# own rather than something a backend projects onto at the end.
sbol3 = "1"
semver = "1"
thiserror = "2"
toml = "0.8"
Expand Down Expand Up @@ -78,6 +83,7 @@ opentrons-protocol = "0.1.0"
lab-package = { path = "crates/lab-package", version = "0.1.2" }
lab-project = { path = "crates/lab-project", version = "0.1.2" }
lab-runfmt = { path = "crates/lab-runfmt", version = "0.1.2" }
lab-sbol = { path = "crates/lab-sbol", version = "0.1.2" }
lab-runtime = { path = "crates/lab-runtime", version = "0.1.2" }
lab-scene = { path = "crates/lab-scene", version = "0.1.2" }

Expand Down
11 changes: 10 additions & 1 deletion crates/lab-compiler/src/lair/source_lowering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,16 @@ fn lower_artifact(
let flow = flow.ok_or_else(|| SourceLoweringError::MissingRealization(name.to_owned()))?;
match kind {
"plasmid" => {
let components = symbols("components", &["Part", "Plasmid"])?;
// Every kind a plasmid's schema admits as a component, which is
// every kind made of DNA. An assembly joins a promoter or a coding
// sequence exactly as it joins a bare part; to a liquid handler
// they are all named items to pipette. This list has to track the
// schema, and reading the kinds' grounding instead of naming them
// is what would stop it drifting.
let components = symbols(
"components",
&["Part", "Plasmid", "Promoter", "CDS", "Backbone"],
)?;
let chemistry = AssemblyChemistryIntent {
reaction_volume_ul: quantity("reaction_volume", "uL", 20)?,
part_volume_ul: quantity("part_volume", "uL", 2)?,
Expand Down
26 changes: 17 additions & 9 deletions crates/lab-compiler/tests/language_specimens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,14 +190,22 @@ fn inventory_specimen_preserves_properties_and_resolved_operations() {
assert_eq!(reporter["kind"], "artifact");
assert_eq!(reporter["artifact"], "plasmid");
assert!(reporter.get("bindings").is_none());
assert!(
reporter["properties"]
.as_array()
.unwrap()
.iter()
.any(|property| property["name"] == "components"
&& property["value"]["type"]["element"]["name"] == "Part")
);
// Each component keeps the kind its catalogue entry was declared with, so
// the list records a promoter driving a coding sequence rather than
// flattening every element to the one kind they have in common.
let components = reporter["properties"]
.as_array()
.unwrap()
.iter()
.find(|property| property["name"] == "components")
.expect("the design states what it is assembled from");
let alternatives = components["value"]["type"]["element"]["alternatives"]
.as_array()
.expect("a heterogeneous component list is a union of its kinds")
.iter()
.map(|alternative| alternative["name"].as_str().unwrap())
.collect::<Vec<_>>();
assert_eq!(alternatives, ["Promoter", "Part", "CDS"]);

// A catalogued name carries its supplier's identifier as a field, so a
// backend reads it directly rather than recognizing a call shape.
Expand All @@ -206,7 +214,7 @@ fn inventory_specimen_preserves_properties_and_resolved_operations() {
.find(|declaration| declaration["kind"] == "catalog" && declaration["name"] == "J23101")
.expect("the specimen catalogues its parts");
assert_eq!(catalogued["identity"], "J23101");
assert_eq!(catalogued["type"]["name"], "Part");
assert_eq!(catalogued["type"]["name"], "Promoter");

let serialized = serde_json::to_string(&module).unwrap();
assert!(serialized.contains("std.bio.build.realize"));
Expand Down
39 changes: 39 additions & 0 deletions crates/lab-language/src/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,33 @@ pub struct Module {
pub span: Span,
}

/// The word instances of a type are written with: the type's own name, in
/// snake_case.
///
/// A break belongs where a word does: after a lowercase run, or at the end of
/// an acronym. `RestrictionEnzyme` gives `restriction_enzyme` and `DNA` gives
/// `dna` rather than `d_n_a`.
///
/// A tool building declarations without parsing them needs this, because a kind
/// names a type and an instance is written with the word. Deriving it anywhere
/// else would let the two disagree.
pub fn instance_word(type_name: &str) -> String {
let characters = type_name.chars().collect::<Vec<_>>();
let mut word = String::new();
for (index, character) in characters.iter().enumerate() {
let previous = index.checked_sub(1).map(|index| characters[index]);
let next = characters.get(index + 1).copied();
let opens_word = previous.is_some_and(|previous| !previous.is_uppercase());
let ends_acronym = previous.is_some_and(char::is_uppercase)
&& next.is_some_and(|next| next.is_lowercase());
if character.is_uppercase() && (opens_word || ends_acronym) {
word.push('_');
}
word.extend(character.to_lowercase());
}
word
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "item", rename_all = "snake_case")]
pub enum Item {
Expand Down Expand Up @@ -47,11 +74,18 @@ impl Item {
/// A role classifies types; it has no values of its own. It carries no members
/// because membership is declared by the type that plays it, which keeps a role
/// open to types declared in other packages.
///
/// A role may name the ontology term it stands for. A role's whole content is
/// its identity, so the term is written after `=` rather than as a property:
/// `role Promoter = "https://identifiers.org/SO:0000167"`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RoleDecl {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doc: Option<String>,
pub name: Identifier,
/// The ontology term this role stands for, where it names one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub term: Option<Identifier>,
pub span: Span,
}

Expand Down Expand Up @@ -92,6 +126,11 @@ pub struct ArtifactKindDecl {
/// The type instances of this kind have, which is what a workflow names in
/// `Material<Plasmid>` and what `require` and `accept` read fields from.
pub produces: TypeExpr,
/// The roles the produced type plays. A kind grounded in an ontology names
/// the terms it stands for this way, so grounding is ordinary membership
/// rather than a mechanism of its own.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub roles: Vec<Path>,
pub fields: Vec<FieldDecl>,
/// Which combinations of stated properties make a declaration complete.
#[serde(default, skip_serializing_if = "Option::is_none")]
Expand Down
19 changes: 17 additions & 2 deletions crates/lab-language/src/checked.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@ use crate::semantics::{DefinitionId, ModuleId, ModuleInterface};
/// written against an earlier version cannot read this one: `Catalog` is a
/// declaration of its own and carries the properties its item states, `Data`
/// carries no category, a schema field states whether an instance may omit it,
/// and an acceptance claim carries the evidence it is believed on.
pub const PORTABLE_MODULE_SCHEMA_VERSION: &str = "lab.portable-module.v3";
/// an acceptance claim carries the evidence it is believed on, a role may name
/// the ontology term it stands for, and an artifact kind carries the roles its
/// produced type plays.
///
/// A consumer that ignores the last two reads a design with nothing said about
/// what it is, which is exactly the silence grounding exists to end. That is
/// why they raise the version rather than riding along as optional fields.
pub const PORTABLE_MODULE_SCHEMA_VERSION: &str = "lab.portable-module.v4";

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckedModule {
Expand Down Expand Up @@ -43,6 +49,12 @@ pub enum CheckedDeclaration {
#[serde(default, skip_serializing_if = "Option::is_none")]
doc: Option<String>,
name: String,
/// The ontology term this role stands for, in its expanded IRI form.
///
/// A role that names one grounds every type that plays it, which is how
/// a Lab type resolves to the terms a document states about it.
#[serde(default, skip_serializing_if = "Option::is_none")]
term: Option<String>,
},
/// A name a supplier lists, and the Lab type it stands for.
///
Expand Down Expand Up @@ -79,6 +91,9 @@ pub enum CheckedDeclaration {
doc: Option<String>,
name: String,
produces: CheckedType,
/// The roles the produced type plays, in declaration order.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
roles: Vec<String>,
fields: Vec<CheckedSchemaField>,
#[serde(default, skip_serializing_if = "Option::is_none")]
declares: Option<CheckedPresence>,
Expand Down
122 changes: 121 additions & 1 deletion crates/lab-language/src/checker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod context;
mod declarations;
mod expr;
mod interface;
mod ontology;
mod pattern;
mod workflow;

Expand Down Expand Up @@ -75,6 +76,7 @@ impl Checker {
declarations.push(CheckedDeclaration::Role {
doc: declaration.doc.clone(),
name: declaration.name.value.clone(),
term: self.role_terms.get(&declaration.name.value).cloned(),
});
}
Item::ArtifactKind(declaration) => {
Expand All @@ -86,6 +88,7 @@ impl Checker {
doc: declaration.doc.clone(),
name: declaration.name.value.clone(),
produces: to_checked_type(&signature.produces),
roles: declaration.roles.iter().map(path_text).collect(),
fields: signature
.fields
.iter()
Expand Down Expand Up @@ -392,6 +395,116 @@ mod tests {
));
}

/// Grounding is ordinary role membership, so a kind resolves to the terms
/// of every role it plays and a compact identifier reaches the checked IR
/// already expanded.
#[test]
fn a_grounded_kind_resolves_to_its_ontology_terms() {
let module = compile_module(concat!(
"role EngineeredRegion = \"SO:0000804\"\n",
"role NucleicAcid = \"https://identifiers.org/SBO:0000251\"\n",
"\n",
"artifact Plasmid is EngineeredRegion, NucleicAcid\n",
))
.expect("a grounded kind compiles");

let term = module
.declarations
.iter()
.find_map(|declaration| match declaration {
CheckedDeclaration::Role {
name,
term: Some(term),
..
} if name == "EngineeredRegion" => Some(term.clone()),
_ => None,
})
.expect("the role carries its term");
assert_eq!(term, "https://identifiers.org/SO:0000804");

let roles = module
.declarations
.iter()
.find_map(|declaration| match declaration {
CheckedDeclaration::ArtifactKind { name, roles, .. } if name == "plasmid" => {
Some(roles.clone())
}
_ => None,
})
.expect("the kind carries its roles");
assert_eq!(
roles,
vec!["EngineeredRegion".to_owned(), "NucleicAcid".to_owned()]
);
}

/// A role's term is part of its public surface. Without it an importing
/// module could satisfy a bound and still not know what the type is.
#[test]
fn grounding_survives_an_import() {
let mut environment = SemanticEnvironment::default();
let terms = compile_module_with_id(
ModuleId::new("vocab.so"),
"role EngineeredRegion = \"SO:0000804\"\n",
)
.expect("the vocabulary compiles");
environment.insert("vocab.so", terms.interface.clone());

let designs = compile_module_in_environment(
ModuleId::new("designs"),
"use vocab.so\n\nartifact Plasmid is EngineeredRegion\n",
&environment,
)
.expect("a kind grounded in an imported role compiles");

assert_eq!(
designs.interface.exports["plasmid"].roles,
vec!["EngineeredRegion".to_owned()]
);
assert_eq!(
terms.interface.exports["EngineeredRegion"].term.as_deref(),
Some("https://identifiers.org/SO:0000804")
);
}

/// A kind may only be grounded in a role that exists, the same rule a
/// record's `is` clause follows.
#[test]
fn rejects_a_kind_grounded_in_an_undeclared_role() {
let error = compile_module("artifact Plasmid is EngineeredRegion\n")
.expect_err("'EngineeredRegion' is not declared");
let ModuleError::Semantic(error) = error else {
panic!("expected a semantic error, found {error:?}");
};
assert!(error.message.contains("EngineeredRegion"), "{error:?}");
}

/// The term is checked where it is written rather than when a document is
/// emitted, so a typo names the line that made it.
#[test]
fn rejects_a_malformed_ontology_term() {
let error = compile_module("role EngineeredRegion = \"engineered region\"\n")
.expect_err("'engineered region' is not a term");
let ModuleError::Semantic(error) = error else {
panic!("expected a semantic error, found {error:?}");
};
assert!(
error.message.contains("neither an IRI nor a compact"),
"{error:?}"
);
}

/// A role that names no term still classifies types. Grounding is optional,
/// so every existing role keeps working unchanged.
#[test]
fn an_ungrounded_role_carries_no_term() {
let module = compile_module("role Inducible\n").expect("an ungrounded role compiles");
assert!(module.declarations.iter().any(|declaration| matches!(
declaration,
CheckedDeclaration::Role { name, term: None, .. } if name == "Inducible"
)));
}

#[test]
fn emits_stable_module_interfaces_and_resolved_definition_ids() {
let module = compile_module_with_id(
Expand Down Expand Up @@ -512,6 +625,10 @@ mod tests {
// A component list names inventory identities imported from another
// module, and stays a structured list of references rather than
// collapsing into strings.
//
// Each element keeps the kind its catalogue entry was declared with, so
// the list says a promoter drives a coding sequence rather than
// flattening every element to the one kind they have in common.
let components = declarations
.iter()
.find_map(|declaration| {
Expand All @@ -529,7 +646,10 @@ mod tests {
})
})
.unwrap();
assert_eq!(components.value.r#type.display_name(), "List<Part>");
assert_eq!(
components.value.r#type.display_name(),
"List<Promoter | Part | CDS>"
);
let CheckedExpression::List { elements } = &components.value.value else {
panic!("components must remain a structured checked list");
};
Expand Down
18 changes: 18 additions & 0 deletions crates/lab-language/src/checker/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ pub(super) struct SemanticContext {
/// built into the standard library land here together, so a bound is
/// satisfied the same way whichever it came from.
pub type_roles: HashMap<String, BTreeSet<String>>,
/// The ontology term each role stands for, for the roles that name one.
///
/// A role grounded this way is what lets a type be resolved to the terms an
/// SBOL document states about it. A role with no term classifies types and
/// says nothing about any ontology.
pub role_terms: HashMap<String, String>,
}

impl SemanticContext {
Expand Down Expand Up @@ -134,6 +140,7 @@ impl SemanticContext {
artifact_kinds: HashMap::new(),
roles: BTreeSet::new(),
type_roles: HashMap::new(),
role_terms: HashMap::new(),
}
}

Expand Down Expand Up @@ -274,6 +281,9 @@ impl SemanticContext {
}
if export.kind == ExportKind::Role {
self.roles.insert(name.clone());
if let Some(term) = &export.term {
self.role_terms.insert(name.clone(), term.clone());
}
continue;
}
for role in &export.roles {
Expand Down Expand Up @@ -308,6 +318,14 @@ impl SemanticContext {
// so the two travel together.
ExportKind::ArtifactKind => {
if let Some(schema) = &export.schema {
// A kind's roles classify the type it produces, so an
// importer sees the same membership the declaring
// module did and grounds the type the same way.
if let CheckedType::Named { name: produced, .. } = &schema.produces {
for role in &export.roles {
self.add_role(produced, role);
}
}
let fields = schema.fields.iter().map(|field| {
(
field.name.clone(),
Expand Down
Loading
Loading