diff --git a/Cargo.lock b/Cargo.lock index 6a5758e..fd87378 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -264,6 +264,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.12.1" @@ -575,6 +584,15 @@ dependencies = [ "libm", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -633,6 +651,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -675,15 +703,25 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", + "block-buffer 0.12.1", "const-oid", - "crypto-common", + "crypto-common 0.2.2", ] [[package]] @@ -923,6 +961,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1530,6 +1578,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json-event-parser" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "574b0cd5e90ee2ba03a66d0611fc9a09c9a0c28b2ecc2dc8a181dd31a53ca5d7" + [[package]] name = "kamadak-exif" version = "0.6.1" @@ -1539,6 +1593,15 @@ dependencies = [ "mutate_once", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + [[package]] name = "krilla" version = "0.8.2" @@ -1619,7 +1682,7 @@ dependencies = [ "sha2", "tar", "tempfile", - "toml", + "toml 0.8.23", "typst", "typst-kit", "typst-layout", @@ -1643,7 +1706,7 @@ dependencies = [ "serde", "serde_json", "thiserror", - "toml", + "toml 0.8.23", ] [[package]] @@ -1714,7 +1777,7 @@ dependencies = [ "semver", "serde", "thiserror", - "toml", + "toml 0.8.23", ] [[package]] @@ -1723,10 +1786,12 @@ version = "0.1.2" dependencies = [ "lab-language", "lab-package", + "lab-sbol", + "sbol3", "semver", "serde", "thiserror", - "toml", + "toml 0.8.23", ] [[package]] @@ -1738,7 +1803,7 @@ dependencies = [ "serde_json", "tempfile", "thiserror", - "toml", + "toml 0.8.23", ] [[package]] @@ -1755,6 +1820,15 @@ dependencies = [ "thiserror", ] +[[package]] +name = "lab-sbol" +version = "0.1.2" +dependencies = [ + "lab-language", + "sbol3", + "thiserror", +] + [[package]] name = "lab-scene" version = "0.1.2" @@ -2012,6 +2086,85 @@ dependencies = [ "thiserror", ] +[[package]] +name = "oxilangtag" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d3b4eb570abd4a1dcb062c31fd37b832264d9dc7292c3e69acfe926c87b063f" +dependencies = [ + "serde", +] + +[[package]] +name = "oxiri" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4ed3a7192fa19f5f48f99871f2755047fabefd7f222f12a1df1773796a102" + +[[package]] +name = "oxjsonld" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e1380a504a8571763f13b8bcad629ff90d0300d47d8a519f3c6c599625840e" +dependencies = [ + "json-event-parser", + "oxiri", + "oxrdf", + "ryu-js", + "thiserror", +] + +[[package]] +name = "oxrdf" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0afd5c28e4a399c57ee2bc3accd40c7b671fdc7b6537499f14e95b265af7d7e0" +dependencies = [ + "oxilangtag", + "oxiri", + "rand", + "thiserror", +] + +[[package]] +name = "oxrdfio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "696223589ecbcab06b1a5df9b527dc25b0c656160cca38752fdb3878a5d3dd03" +dependencies = [ + "oxjsonld", + "oxrdf", + "oxrdfxml", + "oxttl", + "thiserror", +] + +[[package]] +name = "oxrdfxml" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd5516ae083d09bc57ec65ed5ee97701481725de6ffaa83d968ab42a96157ba1" +dependencies = [ + "oxilangtag", + "oxiri", + "oxrdf", + "quick-xml 0.37.5", + "thiserror", +] + +[[package]] +name = "oxttl" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fd471bd54c23d76631c0a2677aa4bb308d905f6e491ee35dcb0732b7c5c6c" +dependencies = [ + "memchr", + "oxilangtag", + "oxiri", + "oxrdf", + "thiserror", +] + [[package]] name = "palette" version = "0.7.7" @@ -2386,6 +2539,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -2426,6 +2588,8 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ + "libc", + "rand_chacha", "rand_core", ] @@ -2444,6 +2608,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rayon" @@ -2694,6 +2861,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "safe_arch" version = "1.1.0" @@ -2712,6 +2885,64 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "sbol-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8ce81427537a371614e75990f4bd232529f8723194ee3f851da920485893cf" +dependencies = [ + "sbol-rdf", + "thiserror", +] + +[[package]] +name = "sbol-ontology" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f96778ec4bf56dcdec299e44a5cda7b6b44f467fa0e123229596837493f5584c" +dependencies = [ + "clap", + "sha2", + "ureq", +] + +[[package]] +name = "sbol-rdf" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "319f0eb87a0386c973af337435f43eb64e970f1b118a0275564845d7f46f4a18" +dependencies = [ + "oxjsonld", + "oxrdf", + "oxrdfio", + "thiserror", +] + +[[package]] +name = "sbol-rulegen" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14fb61f94454a8c1d0491f19a67c21c578ddf4c1ec8fcda6e56fefaccb10fe68" +dependencies = [ + "serde", + "toml 1.1.4+spec-1.1.0", +] + +[[package]] +name = "sbol3" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bf17db3038c3905c78c82d5200200cb5886c2f47bfd503bf3a53484fede5c7" +dependencies = [ + "sbol-core", + "sbol-ontology", + "sbol-rdf", + "sbol-rulegen", + "sha3", + "thiserror", + "ureq", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2820,6 +3051,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -2840,8 +3080,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", ] [[package]] @@ -3208,11 +3458,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_edit", ] +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -3222,6 +3485,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -3230,10 +3502,19 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", ] [[package]] @@ -3324,7 +3605,7 @@ dependencies = [ "indexmap", "rustc-hash", "stacker", - "toml", + "toml 0.8.23", "typst-library", "typst-macros", "typst-syntax", @@ -3462,7 +3743,7 @@ dependencies = [ "smallvec", "syntect", "time", - "toml", + "toml 0.8.23", "ttf-parser", "two-face", "typed-arena", @@ -3577,7 +3858,7 @@ dependencies = [ "ecow", "rustc-hash", "serde", - "toml", + "toml 0.8.23", "typst-timing", "typst-utils", "unicode-ident", @@ -4200,6 +4481,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "write-fonts" version = "0.48.1" diff --git a/Cargo.toml b/Cargo.toml index a98dedf..3b63ae2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", @@ -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" @@ -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" } diff --git a/crates/lab-compiler/src/lair/source_lowering.rs b/crates/lab-compiler/src/lair/source_lowering.rs index 6025b6c..23ac3cd 100644 --- a/crates/lab-compiler/src/lair/source_lowering.rs +++ b/crates/lab-compiler/src/lair/source_lowering.rs @@ -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)?, diff --git a/crates/lab-compiler/tests/language_specimens.rs b/crates/lab-compiler/tests/language_specimens.rs index ca2a6e8..556c4fb 100644 --- a/crates/lab-compiler/tests/language_specimens.rs +++ b/crates/lab-compiler/tests/language_specimens.rs @@ -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::>(); + 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. @@ -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")); diff --git a/crates/lab-language/src/ast.rs b/crates/lab-language/src/ast.rs index e6236cf..caa0032 100644 --- a/crates/lab-language/src/ast.rs +++ b/crates/lab-language/src/ast.rs @@ -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::>(); + 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 { @@ -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, 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, pub span: Span, } @@ -92,6 +126,11 @@ pub struct ArtifactKindDecl { /// The type instances of this kind have, which is what a workflow names in /// `Material` 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, pub fields: Vec, /// Which combinations of stated properties make a declaration complete. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/lab-language/src/checked.rs b/crates/lab-language/src/checked.rs index 45b27d0..31cadda 100644 --- a/crates/lab-language/src/checked.rs +++ b/crates/lab-language/src/checked.rs @@ -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 { @@ -43,6 +49,12 @@ pub enum CheckedDeclaration { #[serde(default, skip_serializing_if = "Option::is_none")] doc: Option, 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, }, /// A name a supplier lists, and the Lab type it stands for. /// @@ -79,6 +91,9 @@ pub enum CheckedDeclaration { doc: Option, name: String, produces: CheckedType, + /// The roles the produced type plays, in declaration order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + roles: Vec, fields: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] declares: Option, diff --git a/crates/lab-language/src/checker.rs b/crates/lab-language/src/checker.rs index 7c7cb3b..2a9b82c 100644 --- a/crates/lab-language/src/checker.rs +++ b/crates/lab-language/src/checker.rs @@ -5,6 +5,7 @@ mod context; mod declarations; mod expr; mod interface; +mod ontology; mod pattern; mod workflow; @@ -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) => { @@ -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() @@ -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( @@ -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| { @@ -529,7 +646,10 @@ mod tests { }) }) .unwrap(); - assert_eq!(components.value.r#type.display_name(), "List"); + assert_eq!( + components.value.r#type.display_name(), + "List" + ); let CheckedExpression::List { elements } = &components.value.value else { panic!("components must remain a structured checked list"); }; diff --git a/crates/lab-language/src/checker/context.rs b/crates/lab-language/src/checker/context.rs index dc1bf1f..1de23ea 100644 --- a/crates/lab-language/src/checker/context.rs +++ b/crates/lab-language/src/checker/context.rs @@ -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>, + /// 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, } impl SemanticContext { @@ -134,6 +140,7 @@ impl SemanticContext { artifact_kinds: HashMap::new(), roles: BTreeSet::new(), type_roles: HashMap::new(), + role_terms: HashMap::new(), } } @@ -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 { @@ -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(), diff --git a/crates/lab-language/src/checker/declarations.rs b/crates/lab-language/src/checker/declarations.rs index 6e2a022..f5e766d 100644 --- a/crates/lab-language/src/checker/declarations.rs +++ b/crates/lab-language/src/checker/declarations.rs @@ -254,6 +254,11 @@ impl Checker { } if let Item::Role(declaration) = item { self.roles.insert(declaration.name.value.clone()); + if let Some(term) = &declaration.term { + let canonical = super::ontology::check_term(term)?; + self.role_terms + .insert(declaration.name.value.clone(), canonical); + } } } @@ -286,6 +291,27 @@ impl Checker { Item::Role(_) => {} Item::ArtifactKind(declaration) => { let produces = self.lower_kind_type(&declaration.produces)?; + // A kind's roles classify the type it produces, because + // that is the type a workflow names and a bound reads. + let produced_name = match &produces { + Ty::Named(name, _) => name.clone(), + other => { + return Err(SemanticError::new( + declaration.produces.span(), + format!("a kind must produce a named type, found '{other}'"), + )); + } + }; + for role in &declaration.roles { + let name = super::path_text(role); + if !self.roles.contains(&name) { + return Err(self.not_a_role(&name, role.span)); + } + self.type_roles + .entry(produced_name.clone()) + .or_default() + .insert(name); + } // A kind that states no schema of its own takes the // fields of the type it names, which is what a supplier's // item fills in. Stating a schema replaces them. diff --git a/crates/lab-language/src/checker/interface.rs b/crates/lab-language/src/checker/interface.rs index 0749517..6884430 100644 --- a/crates/lab-language/src/checker/interface.rs +++ b/crates/lab-language/src/checker/interface.rs @@ -31,6 +31,7 @@ pub(super) fn build_interface( callable, fields, roles: Vec::new(), + term: None, schema: None, parameters: TypeParameters::default(), documentation: documentation.clone().unwrap_or_default(), @@ -73,15 +74,22 @@ pub(super) fn build_interface( BTreeMap::new(), doc, ), - CheckedDeclaration::Role { doc, name } => insert( - &mut interface, - name, - ExportKind::Role, - None, - None, - BTreeMap::new(), - doc, - ), + CheckedDeclaration::Role { doc, name, term } => { + insert( + &mut interface, + name, + ExportKind::Role, + None, + None, + BTreeMap::new(), + doc, + ); + interface + .exports + .get_mut(name) + .expect("the role export was just inserted") + .term = term.clone(); + } CheckedDeclaration::Circuit { doc, name, @@ -112,6 +120,7 @@ pub(super) fn build_interface( doc, name, produces, + roles, fields, declares, } => { @@ -124,11 +133,12 @@ pub(super) fn build_interface( BTreeMap::new(), doc, ); - interface + let export = interface .exports .get_mut(name) - .expect("the kind export was just inserted") - .schema = Some(crate::semantics::ArtifactSchema { + .expect("the kind export was just inserted"); + export.roles = roles.clone(); + export.schema = Some(crate::semantics::ArtifactSchema { produces: produces.clone(), fields: fields.clone(), declares: declares.clone(), diff --git a/crates/lab-language/src/checker/ontology.rs b/crates/lab-language/src/checker/ontology.rs new file mode 100644 index 0000000..3f9bde4 --- /dev/null +++ b/crates/lab-language/src/checker/ontology.rs @@ -0,0 +1,168 @@ +//! The shape of an ontology term a role names. +//! +//! A term is written as an absolute IRI or as a compact identifier, and this +//! module decides only whether what was written could name a term at all. What +//! the term *means* — whether it exists, which branch it belongs to, whether +//! two terms on one type contradict each other — needs an ontology snapshot, +//! which lives outside this crate so the frontend keeps no data dependency and +//! no network stack. +//! +//! Splitting it here is what lets a single file be checked without resolving a +//! package: a misspelled prefix is caught where it is written, and the +//! membership questions are asked once a whole program is being compiled. + +use crate::semantic_error::SemanticError; +use crate::source::Identifier; + +/// The prefixes SBOL draws terms from, in the spelling `identifiers.org` uses. +/// +/// This list decides only how a compact identifier is recognized, not which +/// terms exist. A term from an ontology not listed here is written as a full +/// IRI, which stays open to vocabularies this crate has never heard of. +const KNOWN_PREFIXES: [&str; 7] = ["CHEBI", "CL", "EDAM", "GO", "NCIT", "SBO", "SO"]; + +/// Checks that `term` could name an ontology term, and returns it in the +/// spelling the rest of the compiler compares against. +/// +/// A compact identifier expands to its `identifiers.org` IRI, so `SO:0000167` +/// and `https://identifiers.org/SO:0000167` are one term written two ways and +/// are not two terms that happen to agree. +pub(super) fn check_term(term: &Identifier) -> Result { + let text = term.value.trim(); + if text.is_empty() { + return Err( + SemanticError::new(term.span, "an ontology term is empty").help( + "write the term a role stands for, such as \"https://identifiers.org/SO:0000167\"", + ), + ); + } + if text != term.value { + return Err( + SemanticError::new(term.span, "an ontology term has surrounding whitespace") + .help("write the term with no leading or trailing spaces"), + ); + } + if text.starts_with("http://") || text.starts_with("https://") { + return check_iri(term, text); + } + check_compact(term, text) +} + +fn check_iri(term: &Identifier, text: &str) -> Result { + let rest = text + .strip_prefix("https://") + .or_else(|| text.strip_prefix("http://")) + .expect("caller checked the scheme"); + if rest.is_empty() || rest.starts_with('/') { + return Err( + SemanticError::new(term.span, format!("'{text}' has no host")).help( + "an ontology term is an absolute IRI, such as \"https://identifiers.org/SO:0000167\"", + ), + ); + } + if text.chars().any(char::is_whitespace) { + return Err(SemanticError::new( + term.span, + format!("'{text}' contains whitespace, so it is not an IRI"), + )); + } + Ok(text.to_owned()) +} + +fn check_compact(term: &Identifier, text: &str) -> Result { + let Some((prefix, local)) = text.split_once(':') else { + return Err(SemanticError::new( + term.span, + format!("'{text}' is neither an IRI nor a compact identifier"), + ) + .help("write a term as \"SO:0000167\" or as the IRI it stands for") + .help("a role with no term classifies types without naming any ontology")); + }; + if local.is_empty() { + return Err(SemanticError::new( + term.span, + format!("'{text}' names the ontology '{prefix}' but no term in it"), + )); + } + let matched = KNOWN_PREFIXES + .iter() + .find(|known| known.eq_ignore_ascii_case(prefix)); + let Some(matched) = matched else { + return Err(SemanticError::new( + term.span, + format!("'{prefix}' is not an ontology this compiler recognizes"), + ) + .help(format!( + "recognized prefixes are {}", + KNOWN_PREFIXES.join(", ") + )) + .help("a term from another vocabulary is written as its full IRI")); + }; + Ok(format!("https://identifiers.org/{matched}:{local}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::source::{Span, Spanned}; + + fn term(text: &str) -> Identifier { + Spanned::new(text.to_owned(), Span::new(0, text.len())) + } + + #[test] + fn a_compact_identifier_expands_to_its_iri() { + assert_eq!( + check_term(&term("SO:0000167")).expect("a known prefix"), + "https://identifiers.org/SO:0000167" + ); + } + + #[test] + fn a_full_iri_is_kept_as_written() { + let iri = "https://identifiers.org/SO:0000167"; + assert_eq!(check_term(&term(iri)).expect("an absolute IRI"), iri); + } + + /// The two spellings must agree, or a type grounded one way would not + /// match a document written the other. + #[test] + fn both_spellings_of_one_term_agree() { + assert_eq!( + check_term(&term("SO:0000167")).expect("compact"), + check_term(&term("https://identifiers.org/SO:0000167")).expect("iri") + ); + } + + #[test] + fn an_iri_from_an_unlisted_vocabulary_is_accepted() { + let iri = "https://lab-lang.org/terms/Reporter"; + assert_eq!(check_term(&term(iri)).expect("an absolute IRI"), iri); + } + + #[test] + fn rejects_an_unknown_compact_prefix() { + let error = check_term(&term("XX:1")).expect_err("an unknown prefix"); + assert!(error.message.contains("not an ontology"), "{error:?}"); + } + + #[test] + fn rejects_a_bare_word() { + let error = check_term(&term("promoter")).expect_err("not a term"); + assert!( + error.message.contains("neither an IRI nor a compact"), + "{error:?}" + ); + } + + #[test] + fn rejects_an_empty_term() { + assert!(check_term(&term("")).is_err()); + } + + #[test] + fn rejects_a_prefix_with_no_local_part() { + let error = check_term(&term("SO:")).expect_err("no local part"); + assert!(error.message.contains("no term in it"), "{error:?}"); + } +} diff --git a/crates/lab-language/src/lib.rs b/crates/lab-language/src/lib.rs index 9f8ce04..3b3f9d5 100644 --- a/crates/lab-language/src/lib.rs +++ b/crates/lab-language/src/lib.rs @@ -34,7 +34,7 @@ pub use parser::parse_module; pub use render::render_checked_module; pub use semantic_error::{ModuleError, RelatedSpan, SemanticError}; pub use semantics::{ - ArtifactSchema, CallableSignature, DefinitionId, ExportKind, ModuleExport, ModuleId, + ArtifactSchema, CallableSignature, DefinitionId, ExportKind, Grounding, ModuleExport, ModuleId, ModuleInterface, SemanticEnvironment, TypeParameters, }; pub use source::{Identifier, LineIndex, Span, Spanned}; @@ -90,6 +90,22 @@ pub(crate) fn compile_module_with_library( Ok(checked) } +/// Check a module that was built rather than parsed. +/// +/// Designs read from an SBOL document arrive as declarations, not as text, and +/// they still have to satisfy every rule a written module satisfies. Compiling +/// them through the same checker is what makes that true by construction: an +/// undeclared property, an incomplete schema, or a type that does not fit is +/// rejected the same way whichever way the module was produced, and no second +/// implementation of those rules exists to drift. +pub fn compile_ast_module( + module_id: ModuleId, + environment: &SemanticEnvironment, + module: &ast::Module, +) -> Result { + compile_parsed_module(module_id, environment, module) +} + fn compile_parsed_module( module_id: ModuleId, environment: &SemanticEnvironment, diff --git a/crates/lab-language/src/parser.rs b/crates/lab-language/src/parser.rs index 9f10cdb..4f7bdf4 100644 --- a/crates/lab-language/src/parser.rs +++ b/crates/lab-language/src/parser.rs @@ -20,23 +20,7 @@ fn instance_word(produces: &TypeExpr) -> Result { "an artifact kind names a type declared here or imported, not a path", )); }; - // 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`. - let characters = segment.value.chars().collect::>(); - 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()); - } - Ok(word) + Ok(crate::ast::instance_word(&segment.value)) } /// Parse a complete Lab source module without lowering it. @@ -147,10 +131,18 @@ impl<'a> Parser<'a> { fn parse_role(&mut self) -> Result { let start = self.expect_word("role")?.span; let name = self.take_identifier("a role name")?; + // A role has no content but its identity, so an ontology term is + // written after `=` rather than as a property in a block. + let term = if self.consume(&TokenKind::Equal).is_some() { + Some(self.take_string("an ontology term")?) + } else { + None + }; let end = self.expect_line_end()?; Ok(RoleDecl { doc: None, name, + term, span: start.join(end), }) } @@ -256,6 +248,9 @@ impl<'a> Parser<'a> { // twice and the two can never disagree. let produces = self.parse_type()?; let name = Identifier::new(instance_word(&produces)?, produces.span()); + // The same `is` clause a record uses. A kind grounded in an ontology + // states the terms it stands for as roles it plays. + let roles = self.parse_roles_clause()?; // A kind whose instances state nothing beyond their name needs no // block, the way a role needs none. if !self.check(&TokenKind::Colon) { @@ -264,6 +259,7 @@ impl<'a> Parser<'a> { doc: None, name, produces, + roles, fields: Vec::new(), declares: None, span: start.join(end), @@ -292,6 +288,7 @@ impl<'a> Parser<'a> { doc: None, name, produces, + roles, fields, declares, span: start.join(end), @@ -1336,6 +1333,22 @@ impl<'a> Parser<'a> { } } + fn take_string(&mut self, expected: &str) -> Result { + let token = self.next().ok_or_else(|| { + syntax_span( + Span::at(self.source.len()), + format!("expected {expected}, found end of input"), + ) + })?; + match token.kind { + TokenKind::String(value) => Ok(Spanned::new(value, token.span)), + found => Err(syntax_span( + token.span, + format!("expected {expected}, found {found}"), + )), + } + } + fn expect(&mut self, expected: TokenKind) -> Result { let token = self.next().ok_or_else(|| { syntax_span( diff --git a/crates/lab-language/src/semantics/grounding.rs b/crates/lab-language/src/semantics/grounding.rs new file mode 100644 index 0000000..0cb21b8 --- /dev/null +++ b/crates/lab-language/src/semantics/grounding.rs @@ -0,0 +1,243 @@ +//! What ontology terms a Lab type stands for. +//! +//! Grounding is ordinary role membership: a role may name a term, a type plays +//! roles, and the terms of the roles it plays are what a document states about +//! it. Both halves are already part of a module's public surface, so this joins +//! them rather than introducing a channel of its own. +//! +//! A consumer builds one index over every module in scope and asks it about a +//! type. Reading a single module is not enough: the role usually comes from a +//! vocabulary package and the membership from a design package, and neither +//! knows the whole answer alone. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::checked::{CheckedDeclaration, CheckedModule, CheckedType}; +use crate::semantics::{ExportKind, ModuleInterface}; + +/// The terms every type in scope stands for. +/// +/// Ordered so that a document built from it lists the same terms in the same +/// order on every build; a diff between two builds should be a change in the +/// design, never a change in iteration order. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Grounding { + /// The term each grounded role names. + role_terms: BTreeMap, + /// The roles each type plays. + type_roles: BTreeMap>, + /// Which module declares the kind that produces each type. + /// + /// A design read from a document names kinds without saying where they come + /// from, because a document states what its components are and not which + /// Lab package describes them. Recording this is what lets such a module + /// import exactly the packages the kinds it used are declared in, rather + /// than being told or guessing. + kind_modules: BTreeMap, +} + +impl Grounding { + /// An index already holding the standard library's own grounding. + /// + /// Every program imports some of `std`, and the terms its kinds stand for + /// are stated there rather than in the program, so starting empty would + /// make the common case answer nothing. + pub fn bundled() -> Self { + let mut grounding = Self::default(); + for interface in crate::standard_library::authored_interfaces().values() { + grounding.add_interface(interface); + } + grounding + } + + /// Adds everything one interface publishes. + /// + /// Call this for every module in scope, including the standard library, + /// before asking any question. + pub fn add_interface(&mut self, interface: &ModuleInterface) { + for (name, export) in &interface.exports { + match export.kind { + ExportKind::Role => { + if let Some(term) = &export.term { + self.role_terms.insert(name.clone(), term.clone()); + } + } + // A kind's roles classify the type it produces, which is the + // type a design names and a document is written about. + ExportKind::ArtifactKind => { + let Some(schema) = &export.schema else { + continue; + }; + if let CheckedType::Named { name, .. } = &schema.produces { + self.extend_roles(name, &export.roles); + // Several packages may describe one kind, and importing + // any of them puts the word in scope, so the first that + // declares it is enough to reach it by. + self.kind_modules + .entry(name.clone()) + .or_insert_with(|| interface.module.to_string()); + } + } + ExportKind::Type => self.extend_roles(name, &export.roles), + _ => {} + } + } + } + + /// Adds the declarations of a module being compiled, whose own grounding is + /// not visible through an interface it has not published yet. + pub fn add_module(&mut self, module: &CheckedModule) { + for declaration in &module.declarations { + match declaration { + CheckedDeclaration::Role { + name, + term: Some(term), + .. + } => { + self.role_terms.insert(name.clone(), term.clone()); + } + CheckedDeclaration::ArtifactKind { + produces: CheckedType::Named { name, .. }, + roles, + .. + } => self.extend_roles(name, roles), + CheckedDeclaration::Data { name, roles, .. } => self.extend_roles(name, roles), + _ => {} + } + } + } + + fn extend_roles(&mut self, ty: &str, roles: &[String]) { + if roles.is_empty() { + return; + } + self.type_roles + .entry(ty.to_owned()) + .or_default() + .extend(roles.iter().cloned()); + } + + /// The terms a type stands for. + /// + /// A parameterized type is grounded by its head: `Promoter` + /// is a promoter whatever it responds to. A type that plays no grounded + /// role yields nothing, which a caller must treat as "not stated" rather + /// than as a claim that the type is uncharacterized. + pub fn terms(&self, ty: &CheckedType) -> BTreeSet { + let CheckedType::Named { name, .. } = ty else { + return BTreeSet::new(); + }; + self.terms_for_name(name) + } + + /// The terms the type named `name` stands for. + pub fn terms_for_name(&self, name: &str) -> BTreeSet { + self.type_roles + .get(name) + .into_iter() + .flatten() + .filter_map(|role| self.role_terms.get(role).cloned()) + .collect() + } + + /// The term a role names, where it names one. + pub fn role_term(&self, role: &str) -> Option<&str> { + self.role_terms.get(role).map(String::as_str) + } + + /// The module declaring the kind that produces `type_name`, which is what a + /// module using that kind has to import to reach it. + pub fn module_declaring(&self, type_name: &str) -> Option<&str> { + self.kind_modules.get(type_name).map(String::as_str) + } + + /// Every type that stands for at least one term, with the terms it stands + /// for. This is what a reader inverts to recognize a type from a document. + pub fn grounded_types(&self) -> impl Iterator)> { + self.type_roles.keys().filter_map(|name| { + let terms = self.terms_for_name(name); + (!terms.is_empty()).then_some((name.as_str(), terms)) + }) + } + + /// Whether any role in scope names a term. A program that grounds nothing + /// is a program an SBOL emitter has nothing to say about. + pub fn is_empty(&self) -> bool { + self.role_terms.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::semantics::{ModuleId, SemanticEnvironment}; + use crate::{compile_module_in_environment, compile_module_with_id}; + + /// The bundled standard library grounds its own kinds, so a program that + /// imports `std.bio.designs` and states nothing about ontologies still + /// describes its plasmids in terms an SBOL tool reads. + #[test] + fn the_standard_library_grounds_its_design_kinds() { + let module = compile_module_with_id( + ModuleId::new("designs"), + "use std.bio.designs\n\nbuild plasmid p:\n sequence = dna(\"ACGT\")\n", + ) + .expect("the module compiles"); + + let mut grounding = Grounding::bundled(); + grounding.add_module(&module); + + assert_eq!( + grounding.terms_for_name("Plasmid"), + BTreeSet::from([ + "https://identifiers.org/SBO:0000251".to_owned(), + "https://identifiers.org/SO:0000804".to_owned(), + ]) + ); + assert_eq!( + grounding.terms_for_name("Antibiotic"), + BTreeSet::from(["https://identifiers.org/SBO:0000247".to_owned()]) + ); + } + + /// A vocabulary package and a design package each hold half the answer, so + /// the index must span both. + #[test] + fn grounding_joins_a_role_and_a_membership_from_two_modules() { + let mut environment = SemanticEnvironment::default(); + let vocabulary = compile_module_with_id( + ModuleId::new("vocab"), + "role EngineeredRegion = \"SO:0000804\"\n", + ) + .expect("the vocabulary compiles"); + environment.insert("vocab", vocabulary.interface.clone()); + + let designs = compile_module_in_environment( + ModuleId::new("designs"), + "use vocab\n\nartifact Plasmid is EngineeredRegion\n", + &environment, + ) + .expect("the designs compile"); + + let mut grounding = Grounding::default(); + grounding.add_interface(&vocabulary.interface); + grounding.add_interface(&designs.interface); + + assert_eq!( + grounding.terms_for_name("Plasmid"), + BTreeSet::from(["https://identifiers.org/SO:0000804".to_owned()]) + ); + } + + /// An ungrounded type is silent rather than wrong. Nothing is claimed about + /// a type whose roles name no term. + #[test] + fn an_ungrounded_type_yields_no_terms() { + let module = compile_module_with_id(ModuleId::new("m"), "role Inducible\n") + .expect("the module compiles"); + let mut grounding = Grounding::default(); + grounding.add_module(&module); + assert!(grounding.terms_for_name("Inducible").is_empty()); + assert!(grounding.is_empty()); + } +} diff --git a/crates/lab-language/src/semantics/interface.rs b/crates/lab-language/src/semantics/interface.rs index 018c35c..2a84b4f 100644 --- a/crates/lab-language/src/semantics/interface.rs +++ b/crates/lab-language/src/semantics/interface.rs @@ -27,8 +27,18 @@ pub struct ModuleExport { pub fields: BTreeMap, /// For a type export, the roles it plays. Membership is part of a type's /// public surface: an importer cannot satisfy a bound without it. + /// + /// For an artifact-kind export these classify the type the kind produces, + /// because that is the type a workflow names and a bound reads. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub roles: Vec, + /// For a role export, the ontology term it stands for. + /// + /// Grounding is part of a role's public surface for the same reason + /// membership is: an importer that cannot see the term cannot resolve a + /// type that plays the role to the terms a document states about it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub term: Option, /// For an artifact-kind export, the schema its declarations are checked /// against. A word means nothing to an importer without it. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -106,6 +116,12 @@ impl SemanticEnvironment { self.modules.insert(visible_name.into(), interface); } + /// Every interface in scope, for a consumer that has to look across all of + /// them rather than resolve one by name. + pub fn interfaces(&self) -> impl Iterator { + self.modules.values() + } + pub fn extend(&mut self, other: &Self) { self.modules.extend(other.modules.clone()); } diff --git a/crates/lab-language/src/semantics/mod.rs b/crates/lab-language/src/semantics/mod.rs index eea01f3..2f106e6 100644 --- a/crates/lab-language/src/semantics/mod.rs +++ b/crates/lab-language/src/semantics/mod.rs @@ -4,9 +4,11 @@ //! these identities so that packages, the IDE, and lowering agree on which //! declaration a reference denotes. +mod grounding; mod ids; mod interface; +pub use grounding::Grounding; pub use ids::{DefinitionId, ModuleId}; pub use interface::{ ArtifactSchema, CallableSignature, ExportKind, ModuleExport, ModuleInterface, diff --git a/crates/lab-language/src/standard_library/authored/designs.lab b/crates/lab-language/src/standard_library/authored/designs.lab index a56ea83..7b24448 100644 --- a/crates/lab-language/src/standard_library/authored/designs.lab +++ b/crates/lab-language/src/standard_library/authored/designs.lab @@ -5,19 +5,35 @@ * snake_case. The word is vocabulary a package supplies; the compiler only * knows the shape. Whether any one thing was built or bought is stated by the * declaration that names it, not by its kind. + * + * Each kind states the ontology terms it stands for, so what it is travels with + * it. A target reading a design knows a backbone is DNA and an antibiotic is a + * small molecule without being told separately. */ -/** A part a supplier lists, ordered rather than built. */ -artifact Part +use std.bio.ontology + +/** + * A part a supplier lists, ordered rather than built. + * + * A part is made of DNA, so it may state the DNA it is made of. A catalogue + * that lists a part usually publishes its sequence, and a design that names the + * part is entitled to read it. + */ +artifact Part is NucleicAcid: + sequence?: DNA /** A promoter for some signal. */ -artifact Promoter +artifact Promoter is NucleicAcid, PromoterRegion: + sequence?: DNA /** A coding sequence for some protein. */ -artifact CDS +artifact CDS is NucleicAcid, CodingSequence: + sequence?: DNA /** An assembly backbone. */ -artifact Backbone +artifact Backbone is NucleicAcid, EngineeredRegion: + sequence?: DNA /** * A type IIS enzyme that opens a backbone. @@ -26,7 +42,7 @@ artifact Backbone * every plasmid cut with the same enzyme cuts the same way. A design may still * state its own where a protocol departs from the datasheet. */ -artifact RestrictionEnzyme: +artifact RestrictionEnzyme is Macromolecule: digest_temperature?: Quantity digest_duration?: Quantity @@ -37,14 +53,14 @@ artifact RestrictionEnzyme: * shock and recovery belong to the chassis rather than to each strain built in * it. */ -artifact Chassis: +artifact Chassis is FunctionalEntity: heat_shock_temperature?: Quantity cold_incubation?: Quantity recovery_temperature?: Quantity recovery_duration?: Quantity /** A selection agent a transformed culture is plated on. */ -artifact Antibiotic +artifact Antibiotic is SimpleChemical /** * A DNA design a laboratory can build. @@ -52,11 +68,16 @@ artifact Antibiotic * A plasmid states its sequence directly, or states the backbone together with * what goes into it: the parts an assembly joins, or the circuit a sequence can * be derived from. + * + * What an assembly joins is anything made of DNA, which is what `any + * NucleicAcid` says. Naming the admissible kinds instead would be a list that + * every new kind of part has to be added to, and a promoter or a coding + * sequence is no less assemblable than a bare part. */ -artifact Plasmid: +artifact Plasmid is NucleicAcid, EngineeredRegion: sequence?: DNA backbone?: Backbone - components?: List + components?: List cargo?: Circuit declares sequence or (backbone and components) or (backbone and cargo) @@ -67,7 +88,7 @@ artifact Plasmid: * The same plasmid in two hosts is two artifacts, each with its own acceptance * criteria and its own place in a build order. */ -artifact Strain: +artifact Strain is FunctionalEntity: chassis: Chassis plasmids: List selection?: Antibiotic diff --git a/crates/lab-language/src/standard_library/authored/ontology.lab b/crates/lab-language/src/standard_library/authored/ontology.lab new file mode 100644 index 0000000..1d036b5 --- /dev/null +++ b/crates/lab-language/src/standard_library/authored/ontology.lab @@ -0,0 +1,70 @@ +/*! + * The ontology terms a synthetic-biology design is described in. + * + * A role here names a term rather than classifying a Lab type on its own. A + * package grounds its kinds by playing these roles, so what a plasmid *is* + * travels in a vocabulary every SBOL tool already reads, and the compiler never + * has to guess whether a named item is DNA, a protein, or a reagent. + * + * Terms come from three ontologies, and each answers a different question. + * SBO says what kind of physical entity something is. SO says what part it + * plays in a sequence. EDAM says how a sequence is written down. + */ + +// --- What kind of entity something is (SBO) ------------------------------- + +/** A nucleic acid: DNA or RNA. */ +role NucleicAcid = "SBO:0000251" + +/** A protein, which is what a coding sequence expresses. */ +role Macromolecule = "SBO:0000252" + +/** A small molecule: an inducer, an antibiotic, a buffer component. */ +role SimpleChemical = "SBO:0000247" + +/** + * An entity described by what it does rather than what it is made of. + * + * This is the term SBOL falls back to when nothing more specific is known, so + * a kind that plays it is saying only that it participates in a design. + */ +role FunctionalEntity = "SBO:0000241" + +// --- What part of a sequence something is (SO) ---------------------------- + +/** A region deliberately assembled rather than found. */ +role EngineeredRegion = "SO:0000804" + +/** + * A region transcription begins at. + * + * Named for the region rather than the part because roles and types share one + * namespace, and `Promoter` is already the kind a supplier lists. + */ +role PromoterRegion = "SO:0000167" + +/** A region translated into a protein. */ +role CodingSequence = "SO:0000316" + +/** Where a ribosome binds ahead of a coding sequence. */ +role RibosomeEntrySite = "SO:0000139" + +/** Where transcription stops. */ +role Terminator = "SO:0000141" + +/** A region a repressor or activator binds. */ +role Operator = "SO:0000057" + +/** A sequence with no free ends. */ +role CircularTopology = "SO:0000988" + +/** A sequence with two free ends. */ +role LinearTopology = "SO:0000987" + +// --- How a sequence is written down (EDAM) -------------------------------- + +/** Nucleotides written in the IUPAC alphabet. */ +role IupacNucleicAcid = "EDAM:format_1207" + +/** Amino acids written in the IUPAC alphabet. */ +role IupacProtein = "EDAM:format_1208" diff --git a/crates/lab-language/src/standard_library/catalog.rs b/crates/lab-language/src/standard_library/catalog.rs index 7256725..6142a35 100644 --- a/crates/lab-language/src/standard_library/catalog.rs +++ b/crates/lab-language/src/standard_library/catalog.rs @@ -309,6 +309,7 @@ impl StandardModule { /// the ones before it and nothing after, so the bootstrap is a straight line /// rather than a graph to resolve. const AUTHORED_SOURCES: &[(&str, &str)] = &[ + ("std.bio.ontology", include_str!("authored/ontology.lab")), ("std.bio.designs", include_str!("authored/designs.lab")), ("std.bio.parts", include_str!("authored/parts.lab")), ("std.bio.backbones", include_str!("authored/backbones.lab")), @@ -321,6 +322,11 @@ const AUTHORED_SOURCES: &[(&str, &str)] = &[ static AUTHORED: OnceLock>> = OnceLock::new(); +/// The compiled interfaces of the Lab-written standard modules. +pub(crate) fn authored_interfaces() -> Arc> { + authored_modules() +} + /// Compile the Lab-written standard modules once for the life of the process. /// /// A checker is built for every module compiled, so doing this eagerly on each diff --git a/crates/lab-language/src/standard_library/mod.rs b/crates/lab-language/src/standard_library/mod.rs index 75d4926..5ad77f0 100644 --- a/crates/lab-language/src/standard_library/mod.rs +++ b/crates/lab-language/src/standard_library/mod.rs @@ -18,3 +18,14 @@ pub(crate) use contract::{ActionContractSpec, ContractType, Lineage, PhrasePart} pub(crate) fn render_markdown() -> String { StandardLibrary::bundled().render_markdown() } + +/// The interfaces of the standard modules written in Lab. +/// +/// A consumer that resolves a type to what it stands for needs these: the +/// grounding of `Plasmid` lives in `std.bio.designs` and the terms it names +/// live in `std.bio.ontology`, so a program that imports them states neither +/// itself. +pub(crate) fn authored_interfaces() +-> std::sync::Arc> { + catalog::authored_interfaces() +} diff --git a/crates/lab-package/src/lib.rs b/crates/lab-package/src/lib.rs index 45d0bae..bdec929 100644 --- a/crates/lab-package/src/lib.rs +++ b/crates/lab-package/src/lib.rs @@ -9,6 +9,9 @@ pub use manifest::{ BuildMetadata, DependencyDetail, DependencySpec, InventoryMetadata, LabManifest, PackageManifest, PackageMetadata, WorkspaceManifest, WorkspaceMetadata, }; -pub use package::{DiscoveredRoot, LabPackage, LabWorkspace, PackageError, PackageSource}; +pub use package::{ + DiscoveredRoot, LabPackage, LabWorkspace, PackageError, PackageSource, SbolSyntax, + SourceLanguage, +}; pub const MANIFEST_FILE: &str = "lab.toml"; diff --git a/crates/lab-package/src/package.rs b/crates/lab-package/src/package.rs index 830eb44..be80b76 100644 --- a/crates/lab-package/src/package.rs +++ b/crates/lab-package/src/package.rs @@ -8,11 +8,51 @@ use crate::{ WorkspaceManifest, }; +/// What language a source module is written in. +/// +/// A laboratory writes its designs in Lab or in SBOL, so a package holds both +/// and the module either is compiled the same way once it is checked. The +/// distinction lives here rather than being re-derived from a file extension +/// wherever a source is read. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SourceLanguage { + /// Lab source text. + Lab, + /// An SBOL document, in whichever RDF serialization its extension names. + Sbol(SbolSyntax), +} + +/// The RDF serialization an SBOL document is written in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SbolSyntax { + Turtle, + NTriples, + JsonLd, + RdfXml, +} + +impl SbolSyntax { + /// The syntax an extension names, for the extensions that name one + /// unambiguously. `.xml` and `.json` are deliberately absent: either could + /// be several things, and guessing wrong produces a parse error that blames + /// the document rather than the guess. + pub fn from_extension(extension: &str) -> Option { + match extension { + "ttl" => Some(Self::Turtle), + "nt" => Some(Self::NTriples), + "jsonld" => Some(Self::JsonLd), + "rdf" => Some(Self::RdfXml), + _ => None, + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PackageSource { pub module: String, pub path: PathBuf, pub relative_path: PathBuf, + pub language: SourceLanguage, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -203,6 +243,8 @@ impl LabPackage { let module_path = path.strip_prefix(root.join("src")).unwrap_or(&path); PackageSource { module: module_name(&namespace, module_path), + language: source_language(&path) + .expect("discovery only collects files that name a language"), path, relative_path, } @@ -268,15 +310,22 @@ fn collect_sources(directory: &Path, files: &mut Vec) -> Result<(), Pac })?; if file_type.is_dir() { collect_sources(&path, files)?; - } else if file_type.is_file() - && path.extension().is_some_and(|extension| extension == "lab") - { + } else if file_type.is_file() && source_language(&path).is_some() { files.push(path); } } Ok(()) } +/// What language a file is written in, or `None` if it is not a source module. +fn source_language(path: &Path) -> Option { + let extension = path.extension()?.to_str()?; + if extension == "lab" { + return Some(SourceLanguage::Lab); + } + SbolSyntax::from_extension(extension).map(SourceLanguage::Sbol) +} + fn module_name(namespace: &str, relative: &Path) -> String { let mut segments = vec![namespace.to_owned()]; for component in relative.components() { @@ -284,7 +333,13 @@ fn module_name(namespace: &str, relative: &Path) -> String { continue; }; let mut segment = component.to_string_lossy().into_owned(); - if let Some(stem) = segment.strip_suffix(".lab") { + // A module is named for its file, whichever language the file is + // written in, so moving a design from Lab to SBOL does not rename it. + if let Some(stem) = Path::new(&segment) + .file_stem() + .filter(|_| source_language(Path::new(&segment)).is_some()) + .and_then(|stem| stem.to_str()) + { segment = stem.to_owned(); } segments.push(segment.replace('-', "_")); diff --git a/crates/lab-project/Cargo.toml b/crates/lab-project/Cargo.toml index 98f96b0..17813d5 100644 --- a/crates/lab-project/Cargo.toml +++ b/crates/lab-project/Cargo.toml @@ -10,6 +10,8 @@ description = "Package-aware project loading and compilation for Lab." [dependencies] lab-language.workspace = true lab-package.workspace = true +lab-sbol.workspace = true +sbol3.workspace = true semver.workspace = true serde.workspace = true thiserror.workspace = true diff --git a/crates/lab-project/src/lib.rs b/crates/lab-project/src/lib.rs index 92c898b..11bbdef 100644 --- a/crates/lab-project/src/lib.rs +++ b/crates/lab-project/src/lib.rs @@ -7,13 +7,17 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::{Path, PathBuf}; +use lab_language::Grounding; use lab_language::{ CheckedDeclaration, CheckedModule, ModuleId, SemanticEnvironment, compile_module_in_environment, parse_module, }; use lab_package::{ DependencySpec, DiscoveredRoot, LabPackage, LabWorkspace, PackageError, PackageSource, + SbolSyntax, SourceLanguage, }; +use lab_sbol::KindIndex; +use sbol3::{Document, RdfFormat}; use semver::{Version, VersionReq}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -352,6 +356,60 @@ impl LabProject { } } +/// Compiles one SBOL document into a checked module. +/// +/// Components that no kind in scope describes are skipped rather than fatal. +/// A registry export is large and partly outside any one program's vocabulary, +/// and refusing a whole file over one unrecognized term would make writing +/// designs in SBOL unusable against real registry data. What was skipped is +/// reported through the module's diagnostics rather than discarded silently. +fn compile_sbol_module( + name: &str, + text: &str, + syntax: SbolSyntax, + environment: &SemanticEnvironment, +) -> Result { + let format = match syntax { + SbolSyntax::Turtle => RdfFormat::Turtle, + SbolSyntax::NTriples => RdfFormat::NTriples, + SbolSyntax::JsonLd => RdfFormat::JsonLd, + SbolSyntax::RdfXml => RdfFormat::RdfXml, + }; + let document = Document::read(text, format).map_err(|error| ProjectError::Parse { + module: name.to_owned(), + message: error.to_string(), + })?; + + let mut grounding = Grounding::bundled(); + for interface in environment.interfaces() { + grounding.add_interface(interface); + } + let kinds = KindIndex::new(&grounding); + + let (module, skipped) = lab_sbol::read_module( + ModuleId::new(name.to_owned()), + &document, + &kinds, + environment, + ); + let module = module.map_err(|error| ProjectError::Compile { + module: name.to_owned(), + message: error.to_string(), + })?; + if !skipped.is_empty() { + let detail = skipped + .iter() + .map(|skipped| skipped.reason.to_string()) + .collect::>() + .join("; "); + return Err(ProjectError::Compile { + module: name.to_owned(), + message: format!("this document states designs Lab cannot read: {detail}"), + }); + } + Ok(module) +} + fn compile_package( package: &LabPackage, mut environment: SemanticEnvironment, @@ -367,27 +425,36 @@ fn compile_package( path: source.path.clone(), source: source_error, })?; - let syntax = parse_module(&text).map_err(|error| ProjectError::Parse { - module: source.module.clone(), - message: error.to_string(), - })?; - let local_imports = syntax - .items - .iter() - .filter_map(|item| { - let lab_language::ast::Item::Use(import) = item else { - return None; - }; - let path = import - .path - .segments + // A document names no sibling module. It describes components and the + // terms they stand for, and which package declares the kinds those + // terms name is derived when it is read, so it depends on nothing + // inside this package and is ready to compile from the start. + let local_imports = match source.language { + SourceLanguage::Sbol(_) => BTreeSet::new(), + SourceLanguage::Lab => { + let syntax = parse_module(&text).map_err(|error| ProjectError::Parse { + module: source.module.clone(), + message: error.to_string(), + })?; + syntax + .items .iter() - .map(|segment| segment.value.as_str()) - .collect::>() - .join("."); - local_names.contains(&path).then_some(path) - }) - .collect::>(); + .filter_map(|item| { + let lab_language::ast::Item::Use(import) = item else { + return None; + }; + let path = import + .path + .segments + .iter() + .map(|segment| segment.value.as_str()) + .collect::>() + .join("."); + local_names.contains(&path).then_some(path) + }) + .collect::>() + } + }; remaining.insert(source.module.clone(), (source.clone(), text, local_imports)); } @@ -406,12 +473,18 @@ fn compile_package( } for name in ready { let (source, text, _) = remaining.remove(&name).expect("ready module exists"); - let module = - compile_module_in_environment(ModuleId::new(name.clone()), &text, &environment) - .map_err(|error| ProjectError::Compile { - module: name.clone(), - message: error.to_string(), - })?; + let module = match source.language { + SourceLanguage::Lab => { + compile_module_in_environment(ModuleId::new(name.clone()), &text, &environment) + .map_err(|error| ProjectError::Compile { + module: name.clone(), + message: error.to_string(), + })? + } + SourceLanguage::Sbol(syntax) => { + compile_sbol_module(&name, &text, syntax, &environment)? + } + }; environment.insert(name.clone(), module.interface.clone()); compiled_names.insert(name); result.push(CompiledModule { diff --git a/crates/lab-sbol/Cargo.toml b/crates/lab-sbol/Cargo.toml new file mode 100644 index 0000000..bc47434 --- /dev/null +++ b/crates/lab-sbol/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "lab-sbol" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "SBOL as a source and target for Lab designs." + +[dependencies] +lab-language.workspace = true +sbol3.workspace = true +thiserror.workspace = true + +[lints] +workspace = true diff --git a/crates/lab-sbol/src/kind.rs b/crates/lab-sbol/src/kind.rs new file mode 100644 index 0000000..f209954 --- /dev/null +++ b/crates/lab-sbol/src/kind.rs @@ -0,0 +1,199 @@ +//! Recognizing which Lab kind an SBOL object describes. +//! +//! [`Grounding`] answers "what does this type stand for". Reading a document +//! needs the other direction: given the terms an object states about itself, +//! which Lab type is it. The map is the same map, inverted. +//! +//! Inverting it is not a bijection, and that is the interesting part. Ontology +//! terms say what a thing *is*; a Lab kind also says what part it plays in a +//! method. A backbone and a plasmid are both an engineered region of nucleic +//! acid, and SBOL has no term that separates the vector you cut open from the +//! construct you build. So a term set can name one kind, several, or none, and +//! this module reports which rather than guessing. + +use std::collections::{BTreeMap, BTreeSet}; + +use lab_language::Grounding; +use lab_language::ast::instance_word; + +/// The namespace Lab writes its own statements in. +/// +/// Anything Lab needs to say that SBOL has no vocabulary for lives under this +/// prefix, so a third-party reader can ignore it wholesale and a Lab reader can +/// recognize its own documents. +pub const LAB_NAMESPACE: &str = "https://lab-lang.org/ns#"; + +/// The predicate naming the Lab kind an object was written as. +/// +/// A document Lab emitted carries this, so reading one back recovers the kind +/// exactly rather than inferring it. A document from anywhere else does not, +/// and inference is all there is. +pub const LAB_KIND: &str = "https://lab-lang.org/ns#kind"; + +/// What the terms an object states resolve to. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Resolution { + /// Exactly one kind stands for these terms. + Resolved(String), + /// Several kinds stand for these terms and nothing separates them. The + /// candidates are ordered so a diagnostic lists them the same way twice. + Ambiguous(Vec), + /// No kind in scope stands for these terms. The object may still be + /// readable; it is this program's vocabulary that does not cover it. + Unresolved, +} + +/// Which Lab type each set of ontology terms names. +#[derive(Clone, Debug, Default)] +pub struct KindIndex { + grounded: BTreeMap>, + modules: BTreeMap, +} + +impl KindIndex { + /// Builds the index by inverting a grounding. + pub fn new(grounding: &Grounding) -> Self { + let grounded: BTreeMap> = grounding + .grounded_types() + .map(|(name, terms)| (name.to_owned(), terms)) + .collect(); + let modules = grounded + .keys() + .filter_map(|name| { + grounding + .module_declaring(name) + .map(|module| (name.clone(), module.to_owned())) + }) + .collect(); + Self { grounded, modules } + } + + /// The module a design has to import to name `kind`. + pub fn module_declaring(&self, kind: &str) -> Option<&str> { + self.modules.get(kind).map(String::as_str) + } + + /// The same, found by the word instances are written with rather than by + /// the type's own name, because that is what a declaration records. + pub fn module_for_word(&self, word: &str) -> Option<&str> { + self.modules + .iter() + .find(|(kind, _)| instance_word(kind) == word) + .map(|(_, module)| module.as_str()) + } + + /// The kind an object stating `terms` describes. + /// + /// A candidate is a kind whose every term the object also states, so an + /// object may say more than a kind requires and still be that kind: an SBOL + /// document is free to be more specific than the vocabulary reading it. + /// Among candidates, one that another strictly contains is discarded, since + /// the more specific kind is the better answer. What survives is the answer + /// when it is alone and an ambiguity when it is not. + pub fn resolve(&self, terms: &BTreeSet) -> Resolution { + let candidates: Vec<(&str, &BTreeSet)> = self + .grounded + .iter() + .filter(|(_, required)| required.is_subset(terms)) + .map(|(name, required)| (name.as_str(), required)) + .collect(); + + let maximal: Vec = candidates + .iter() + .filter(|(_, required)| { + !candidates + .iter() + .any(|(_, other)| other.len() > required.len() && required.is_subset(other)) + }) + .map(|(name, _)| (*name).to_owned()) + .collect(); + + match maximal.as_slice() { + [] => Resolution::Unresolved, + [only] => Resolution::Resolved(only.clone()), + _ => Resolution::Ambiguous(maximal), + } + } + + /// The terms a kind stands for, for a caller checking a stated kind against + /// what the object actually says about itself. + pub fn terms(&self, kind: &str) -> Option<&BTreeSet> { + self.grounded.get(kind) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn index() -> KindIndex { + KindIndex::new(&Grounding::bundled()) + } + + fn terms(values: &[&str]) -> BTreeSet { + values + .iter() + .map(|value| format!("https://identifiers.org/{value}")) + .collect() + } + + /// A promoter states one term more than a bare part, and the more specific + /// kind is the answer rather than an ambiguity with `Part`. + #[test] + fn the_most_specific_kind_wins() { + assert_eq!( + index().resolve(&terms(&["SBO:0000251", "SO:0000167"])), + Resolution::Resolved("Promoter".to_owned()) + ); + } + + #[test] + fn a_single_term_resolves_the_kind_that_states_only_it() { + assert_eq!( + index().resolve(&terms(&["SBO:0000247"])), + Resolution::Resolved("Antibiotic".to_owned()) + ); + } + + /// An object may be more specific than the vocabulary reading it. Extra + /// terms narrow nothing away, they just fail to add a candidate. + #[test] + fn unrecognized_extra_terms_do_not_prevent_a_match() { + let mut stated = terms(&["SBO:0000251", "SO:0000167"]); + stated.insert("https://example.org/private/term".to_owned()); + assert_eq!( + index().resolve(&stated), + Resolution::Resolved("Promoter".to_owned()) + ); + } + + /// The bundled Sequence Ontology snapshot has no term separating the vector + /// you cut open from the construct you build, so both kinds stand for the + /// same two terms. Reporting that is the honest answer; picking one would + /// silently mistype half the designs in a registry. + #[test] + fn a_backbone_and_a_plasmid_are_not_distinguishable_by_terms_alone() { + let Resolution::Ambiguous(candidates) = + index().resolve(&terms(&["SBO:0000251", "SO:0000804"])) + else { + panic!("nothing in SBOL separates a backbone from a plasmid"); + }; + assert_eq!( + candidates, + vec!["Backbone".to_owned(), "Plasmid".to_owned()] + ); + } + + #[test] + fn terms_no_kind_states_resolve_to_nothing() { + assert_eq!( + index().resolve(&terms(&["SO:0000694"])), + Resolution::Unresolved + ); + } + + #[test] + fn an_object_stating_no_terms_resolves_to_nothing() { + assert_eq!(index().resolve(&BTreeSet::new()), Resolution::Unresolved); + } +} diff --git a/crates/lab-sbol/src/lib.rs b/crates/lab-sbol/src/lib.rs new file mode 100644 index 0000000..852e583 --- /dev/null +++ b/crates/lab-sbol/src/lib.rs @@ -0,0 +1,20 @@ +//! SBOL as a source and target for Lab designs. +//! +//! A laboratory writes its designs in Lab or in SBOL and its workflows in Lab. +//! That split is not a compromise between two formats; it is where the two +//! languages actually differ. SBOL describes what a thing is and where it came +//! from, and it is unordered, has no binder, no expression language, and no +//! notion of a value being consumed. Lab's workflows are exactly those things. +//! So designs cross the boundary intact and workflows do not cross it at all. +//! +//! This crate owns the crossing. It sits beside `lab-language` rather than +//! inside it because it carries an RDF stack, and `lab-language` is what the +//! editor's WebAssembly build compiles. + +#![forbid(unsafe_code)] + +mod kind; +mod read; + +pub use kind::{KindIndex, LAB_KIND, LAB_NAMESPACE, Resolution}; +pub use read::{Read, ReadError, Skipped, read_designs, read_module}; diff --git a/crates/lab-sbol/src/read.rs b/crates/lab-sbol/src/read.rs new file mode 100644 index 0000000..0315576 --- /dev/null +++ b/crates/lab-sbol/src/read.rs @@ -0,0 +1,835 @@ +//! Reading Lab declarations out of an SBOL document. +//! +//! A design written in SBOL becomes the same checked declaration a design +//! written in Lab becomes, and it becomes one the same way: this module builds +//! declarations and hands them to the checker. Nothing here decides whether a +//! property belongs on a kind, whether a value has the right type, or whether a +//! schema is complete. Those rules have one implementation, and a design read +//! from a document is subject to it exactly as a design typed by hand is. +//! +//! Declarations are built rather than printed. Rendering Lab source and parsing +//! it back would work and would be a mistake: errors would point at text nobody +//! wrote, which is the cost the Python SDK paid for exactly this shortcut. +//! +//! Provenance is the one thing an imported design cannot carry. Whether a +//! laboratory builds a plasmid or orders it is a fact about that laboratory and +//! not about the design, per decision 0027, and a registry has no opinion. An +//! imported component is therefore catalogued: a registry listing something is +//! exactly the claim that you can obtain it. A document Lab wrote says +//! otherwise in its own namespace, and that is honoured where present. + +use std::collections::{BTreeMap, BTreeSet}; + +use lab_language::ast::instance_word; +use lab_language::ast::{ + Argument, ArtifactDecl, ArtifactMember, Expr, Item, Module, Path, PropertyDecl, Provenance, + UseDecl, +}; +use lab_language::{CheckedModule, ModuleError, ModuleId, SemanticEnvironment, Span, Spanned}; +use sbol3::{Component, Document, SbolIdentified, SbolObject, Term}; + +use crate::kind::{KindIndex, LAB_KIND, Resolution}; + +/// The ordering relation SBOL states between two features that abut. +const SBOL_MEETS: &str = "http://sbols.org/v3#meets"; + +/// Why a component could not become a declaration. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum ReadError { + #[error("'{identity}' states no ontology terms, so nothing says what it is")] + Ungrounded { identity: String }, + #[error("'{identity}' is a {candidates}, and nothing in the document says which")] + AmbiguousKind { + identity: String, + candidates: String, + }, + #[error("'{identity}' states terms no kind in scope stands for")] + UnknownKind { identity: String }, + #[error("'{identity}' names the kind '{kind}', which is not in scope")] + StatedKindUnknown { identity: String, kind: String }, + #[error("'{identity}' has no displayId, so it cannot be given a Lab name")] + Unnamed { identity: String }, + #[error("'{identity}' carries {count} sequences, and a design states one")] + SeveralSequences { identity: String, count: usize }, + #[error("'{identity}' refers to '{reference}', which the document does not contain")] + Dangling { identity: String, reference: String }, + #[error("'{identity}' is built from '{feature}', which is not a sub-component of a component")] + UnsupportedFeature { identity: String, feature: String }, + #[error("'{identity}' does not say what order its parts are joined in")] + Unorderable { identity: String }, +} + +/// One component that could not be read, kept beside the ones that could. +/// +/// A registry export is large and partly outside any one program's vocabulary, +/// so refusing the whole document because of one unrecognized component would +/// make the feature unusable. The caller decides which problems are fatal. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Skipped { + pub identity: String, + pub reason: ReadError, +} + +/// The declarations one SBOL document contributed, before checking. +#[derive(Clone, Debug, PartialEq)] +pub struct Read { + pub module: Module, + pub skipped: Vec, +} + +/// Builds the declarations every component in `document` states. +/// +/// The result has not been checked. [`read_module`] returns one that has, and +/// is what a caller normally wants. +pub fn read_designs(document: &Document, kinds: &KindIndex) -> Read { + let mut items = Vec::new(); + let mut skipped = Vec::new(); + for component in document.components() { + match declaration_for(document, component, kinds) { + Ok(declaration) => items.push(Item::Artifact(declaration)), + Err(reason) => skipped.push(Skipped { + identity: component.identity.to_string(), + reason, + }), + } + } + // A registry hands its components back in whatever order its store keeps + // them, and a build should not change because of that. + items.sort_by(|left, right| item_name(left).cmp(item_name(right))); + skipped.sort_by(|left, right| left.identity.cmp(&right.identity)); + Read { + module: Module { + doc: None, + items, + span: Span::new(0, 0), + }, + skipped, + } +} + +/// Reads a document and checks what it read. +/// +/// A document names the terms its components stand for and never says which Lab +/// package describes them, so the imports are derived rather than configured: +/// whichever kinds the document turned out to use, the modules declaring those +/// kinds are what it imports. A registry export needs no accompanying +/// configuration to be readable, and a document that grows a new kind of part +/// does not need one written for it either. +pub fn read_module( + module_id: ModuleId, + document: &Document, + kinds: &KindIndex, + environment: &SemanticEnvironment, +) -> (Result, Vec) { + let read = read_designs(document, kinds); + let mut items: Vec = imports_for(&read.module, kinds) + .into_iter() + .map(|module| { + Item::Use(UseDecl { + path: path(&module), + span: Span::new(0, 0), + }) + }) + .collect(); + items.extend(read.module.items); + let module = Module { + items, + ..read.module + }; + ( + lab_language::compile_ast_module(module_id, environment, &module), + read.skipped, + ) +} + +/// The modules declaring every kind this module's declarations were written +/// with, in a stable order. +fn imports_for(module: &Module, kinds: &KindIndex) -> BTreeSet { + module + .items + .iter() + .filter_map(|item| match item { + Item::Artifact(declaration) => Some(&declaration.kind.value), + _ => None, + }) + .filter_map(|word| kinds.module_for_word(word)) + .map(str::to_owned) + .collect() +} + +fn declaration_for( + document: &Document, + component: &Component, + kinds: &KindIndex, +) -> Result { + let identity = component.identity.to_string(); + let Some(display_id) = component.display_id() else { + return Err(ReadError::Unnamed { identity }); + }; + let kind = kind_of(component, kinds, &identity)?; + + // The registry's own IRI, stated the way a supplier's catalogue number is, + // so an import stays resolvable back to where it came from. + let mut members = vec![property("identity", string(&identity))]; + if let Some(sequence) = sequence_expression(document, component, &identity)? { + members.push(property("sequence", sequence)); + } + if let Some(components) = components_expression(document, component, kinds, &identity)? { + members.push(property("components", components)); + } + + Ok(ArtifactDecl { + doc: documentation(component), + provenance: Provenance::Buy, + kind: name(&instance_word(&kind)), + name: name(display_id), + ascribed: None, + members, + span: Span::new(0, 0), + }) +} + +fn kind_of(component: &Component, kinds: &KindIndex, identity: &str) -> Result { + match stated_kind(component) { + Some(stated) if kinds.terms(&stated).is_some() => Ok(stated), + Some(stated) => Err(ReadError::StatedKindUnknown { + identity: identity.to_owned(), + kind: stated, + }), + None => infer_kind(component, kinds, identity), + } +} + +/// The kind a document Lab wrote states outright. +fn stated_kind(component: &Component) -> Option { + component.extensions().iter().find_map(|extension| { + (extension.predicate.as_str() == LAB_KIND) + .then(|| match &extension.object { + Term::Literal(literal) => Some(literal.value().to_owned()), + // A kind is a word, so a resource here is a document saying + // something this reader has no interpretation for. + _ => None, + }) + .flatten() + }) +} + +fn infer_kind( + component: &Component, + kinds: &KindIndex, + identity: &str, +) -> Result { + let terms: BTreeSet = component + .types + .iter() + .chain(component.roles.iter()) + .map(|iri| iri.as_str().to_owned()) + .collect(); + if terms.is_empty() { + return Err(ReadError::Ungrounded { + identity: identity.to_owned(), + }); + } + match kinds.resolve(&terms) { + Resolution::Resolved(kind) => Ok(kind), + Resolution::Ambiguous(candidates) => Err(ReadError::AmbiguousKind { + identity: identity.to_owned(), + candidates: candidates.join(" or a "), + }), + Resolution::Unresolved => Err(ReadError::UnknownKind { + identity: identity.to_owned(), + }), + } +} + +/// The component's sequence, written the way an author writes one. +/// +/// SBOL lets a component carry several sequences, one per encoding, and Lab's +/// `sequence` is one value of one type. Reading the first of several would pick +/// silently between a nucleotide and a protein spelling of the same design, so +/// several is reported instead. +fn sequence_expression( + document: &Document, + component: &Component, + identity: &str, +) -> Result, ReadError> { + let [only] = component.sequences.as_slice() else { + if component.sequences.len() > 1 { + return Err(ReadError::SeveralSequences { + identity: identity.to_owned(), + count: component.sequences.len(), + }); + } + return Ok(None); + }; + let Some(SbolObject::Sequence(sequence)) = document.resolve(only) else { + return Err(ReadError::Dangling { + identity: identity.to_owned(), + reference: only.to_string(), + }); + }; + let Some(elements) = sequence.elements.as_deref() else { + return Ok(None); + }; + Ok(Some(Expr::Call { + callee: Box::new(Expr::Path(path("dna"))), + arguments: vec![Argument { + name: None, + value: string(elements), + span: Span::new(0, 0), + }], + span: Span::new(0, 0), + })) +} + +/// What the component is assembled from, in the order they are joined. +/// +/// SBOL states order as `meets` constraints between features and Lab states it +/// as the order of a list, so the chain is walked once here and the coordinates +/// it implies are left to be recomputed rather than carried. That is +/// normalization, not loss: a linear chain and an ordered list hold the same +/// fact. +fn components_expression( + document: &Document, + component: &Component, + kinds: &KindIndex, + identity: &str, +) -> Result, ReadError> { + if component.features.is_empty() { + return Ok(None); + } + let ordered = order_features(document, component, kinds, identity)?; + Ok(Some(Expr::List { + elements: ordered + .into_iter() + .map(|part| Expr::Path(path(&part))) + .collect(), + span: Span::new(0, 0), + })) +} + +/// The names of a component's sub-components, ordered head to tail. +/// +/// A single feature needs no chain. Beyond that the `meets` constraints must +/// form one unambiguous line, which is the same shape `compute_sequence` +/// requires, and anything branching or broken is reported rather than +/// linearized arbitrarily: the wrong order silently builds the wrong construct. +fn order_features( + document: &Document, + component: &Component, + kinds: &KindIndex, + identity: &str, +) -> Result, ReadError> { + let mut instance_of = BTreeMap::new(); + for feature in &component.features { + let Some(SbolObject::SubComponent(sub)) = document.resolve(feature) else { + return Err(ReadError::UnsupportedFeature { + identity: identity.to_owned(), + feature: feature.to_string(), + }); + }; + let Some(target) = sub.instance_of.as_ref() else { + return Err(ReadError::UnsupportedFeature { + identity: identity.to_owned(), + feature: feature.to_string(), + }); + }; + let Some(SbolObject::Component(part)) = document.resolve(target) else { + return Err(ReadError::Dangling { + identity: identity.to_owned(), + reference: target.to_string(), + }); + }; + let Some(part_name) = part.display_id() else { + return Err(ReadError::Unnamed { + identity: target.to_string(), + }); + }; + // A part this program has no kind for cannot be referred to, so the + // composite naming it is refused rather than left to dangle. + kind_of(part, kinds, &target.to_string())?; + instance_of.insert(feature.to_string(), part_name.to_owned()); + } + + if instance_of.len() == 1 { + return Ok(instance_of.into_values().collect()); + } + + let mut next = BTreeMap::new(); + let mut has_predecessor = BTreeSet::new(); + for constraint in document.constraints() { + let (Some(subject), Some(object)) = ( + constraint.subject.as_ref(), + constraint.constrained_object.as_ref(), + ) else { + continue; + }; + let (subject, object) = (subject.to_string(), object.to_string()); + if constraint.restriction.as_ref().map(sbol3::Iri::as_str) != Some(SBOL_MEETS) + || !instance_of.contains_key(&subject) + || !instance_of.contains_key(&object) + { + continue; + } + if next.insert(subject, object.clone()).is_some() || !has_predecessor.insert(object) { + return Err(ReadError::Unorderable { + identity: identity.to_owned(), + }); + } + } + + let heads: Vec<&String> = instance_of + .keys() + .filter(|feature| !has_predecessor.contains(*feature)) + .collect(); + let [head] = heads.as_slice() else { + return Err(ReadError::Unorderable { + identity: identity.to_owned(), + }); + }; + + let mut ordered = Vec::with_capacity(instance_of.len()); + let mut cursor = Some((*head).clone()); + while let Some(feature) = cursor { + let Some(part_name) = instance_of.get(&feature) else { + return Err(ReadError::Unorderable { + identity: identity.to_owned(), + }); + }; + ordered.push(part_name.clone()); + if ordered.len() > instance_of.len() { + return Err(ReadError::Unorderable { + identity: identity.to_owned(), + }); + } + cursor = next.get(&feature).cloned(); + } + if ordered.len() != instance_of.len() { + return Err(ReadError::Unorderable { + identity: identity.to_owned(), + }); + } + Ok(ordered) +} + +/// What a reader has to say about a component in prose. +/// +/// SBOL's `name` is a human-readable label and its `description` is prose, and +/// neither is a property any kind declares. They are documentation, and the +/// checker would reject them as properties, which is the point: those rules +/// have one home and this is not it. +fn documentation(component: &Component) -> Option { + match (component.name(), component.description()) { + (Some(name), Some(description)) => Some(format!("{name}. {description}")), + (Some(text), None) | (None, Some(text)) => Some(text.to_owned()), + (None, None) => None, + } +} + +fn name(value: &str) -> Spanned { + Spanned::new(value.to_owned(), Span::new(0, 0)) +} + +fn path(value: &str) -> Path { + Path { + segments: value.split('.').map(name).collect(), + span: Span::new(0, 0), + } +} + +fn string(value: &str) -> Expr { + Expr::String { + value: value.to_owned(), + span: Span::new(0, 0), + } +} + +fn property(field: &str, value: Expr) -> ArtifactMember { + ArtifactMember::Property(PropertyDecl { + name: name(field), + value, + span: Span::new(0, 0), + }) +} + +fn item_name(item: &Item) -> &str { + match item { + Item::Artifact(declaration) => &declaration.name.value, + _ => "", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lab_language::{CheckedDeclaration, Grounding}; + use sbol3::RdfFormat; + + const NAMESPACE: &str = "https://synbiohub.org/public/igem"; + + fn document(components: &[(&str, &[&str], Option<&str>)]) -> Document { + let mut objects = Vec::new(); + for (display_id, terms, name) in components { + let mut builder = sbol3::Component::builder(NAMESPACE, *display_id) + .expect("a valid namespace and displayId"); + for term in *terms { + builder = builder.add_type(sbol3::Iri::new(*term).expect("a valid term IRI")); + } + if let Some(name) = name { + builder = builder.name(*name); + } + objects.push(sbol3::SbolObject::Component( + builder.build().expect("a complete component"), + )); + } + Document::from_objects(objects).expect("distinct identities") + } + + fn kinds() -> KindIndex { + KindIndex::new(&Grounding::bundled()) + } + + fn resource(iri: &str) -> sbol3::Resource { + sbol3::Resource::Iri(sbol3::Iri::new(iri).expect("a valid IRI")) + } + + fn part(display_id: &str, role: &str) -> SbolObject { + SbolObject::Component( + sbol3::Component::builder(NAMESPACE, display_id) + .expect("valid") + .add_type(sbol3::Iri::new(term("SBO:0000251")).expect("valid")) + .add_component_role(sbol3::Iri::new(term(role)).expect("valid")) + .build() + .expect("complete"), + ) + } + + /// A promoter, RBS, coding sequence, and terminator joined head to tail + /// under one composite, with the parts deliberately declared in an order + /// that is not the assembled order so the `meets` chain is what decides. + fn transcription_unit_objects() -> Vec { + let parts = [ + ("p_j23101", "SO:0000167"), + ("term_b0015", "SO:0000141"), + ("cds_gfp", "SO:0000316"), + ("rbs_b0034", "SO:0000139"), + ]; + let mut objects: Vec = parts + .iter() + .map(|(display_id, role)| part(display_id, role)) + .collect(); + + let composite_iri = format!("{NAMESPACE}/tu"); + let sequence = sbol3::Sequence::builder(NAMESPACE, "tu_sequence") + .expect("valid") + .elements("TTGACAGCTAGCATGGGCTAA") + .encoding(sbol3::constants::EDAM_IUPAC_DNA) + .build() + .expect("complete"); + + let mut composite = sbol3::Component::builder(NAMESPACE, "tu") + .expect("valid") + .add_type(sbol3::Iri::new(term("SBO:0000251")).expect("valid")) + .add_component_role(sbol3::Iri::new(term("SO:0000804")).expect("valid")) + .add_sequence(sequence.identity.clone()) + .extension( + sbol3::Iri::new(LAB_KIND).expect("valid"), + Term::Literal(sbol3::Literal::simple("Plasmid")), + ); + + // Assembled order, which is neither declaration order nor the order the + // features sort in: feature `f0` is the *last* part. Nothing but the + // `meets` chain can produce the right answer, so a reader that fell + // back on any incidental ordering would fail this fixture. + let assembled = ["p_j23101", "rbs_b0034", "cds_gfp", "term_b0015"]; + let last = assembled.len() - 1; + let mut features = Vec::new(); + for (index, target) in assembled.iter().enumerate() { + let feature = sbol3::SubComponent::builder( + &resource(&composite_iri), + format!("f{}", last - index), + ) + .expect("valid") + .instance_of(resource(&format!("{NAMESPACE}/{target}"))) + .build() + .expect("complete"); + composite = composite.add_feature(feature.identity.clone()); + features.push(feature); + } + + let composite = composite.build().expect("complete"); + let mut constraints = Vec::new(); + for index in 0..features.len() - 1 { + constraints.push(SbolObject::Constraint( + sbol3::Constraint::builder(&resource(&composite_iri), format!("c{index}")) + .expect("valid") + .subject(features[index].identity.clone()) + .constrained_object(features[index + 1].identity.clone()) + .restriction(sbol3::Iri::from_static(SBOL_MEETS)) + .build() + .expect("complete"), + )); + } + + objects.push(SbolObject::Sequence(sequence)); + objects.push(SbolObject::Component(composite)); + objects.extend(features.into_iter().map(SbolObject::SubComponent)); + objects.extend(constraints); + objects + } + + fn transcription_unit() -> Document { + Document::from_objects(transcription_unit_objects()).expect("distinct identities") + } + + fn term(compact: &str) -> String { + format!("https://identifiers.org/{compact}") + } + + fn environment() -> SemanticEnvironment { + SemanticEnvironment::default() + } + + fn check(document: &Document) -> (CheckedModule, Vec) { + let (checked, skipped) = read_module( + ModuleId::new("registry"), + document, + &kinds(), + &environment(), + ); + ( + checked.unwrap_or_else(|error| panic!("the read module must check: {error}")), + skipped, + ) + } + + fn catalogued<'a>(module: &'a CheckedModule, name: &str) -> &'a CheckedDeclaration { + module + .declarations + .iter() + .find(|declaration| { + matches!(declaration, CheckedDeclaration::Catalog { name: found, .. } if found == name) + }) + .unwrap_or_else(|| panic!("'{name}' is declared")) + } + + /// A registry export becomes catalogued declarations that the checker + /// accepted, typed by what the document says each part is. + #[test] + fn a_registry_export_becomes_checked_catalogued_declarations() { + let document = document(&[ + ( + "BBa_J23101", + &[&term("SBO:0000251"), &term("SO:0000167")], + Some("constitutive promoter"), + ), + ("chlor", &[&term("SBO:0000247")], None), + ]); + let (module, skipped) = check(&document); + assert!(skipped.is_empty(), "{skipped:?}"); + + let CheckedDeclaration::Catalog { + r#type, + identity, + doc, + .. + } = catalogued(&module, "BBa_J23101") + else { + panic!("catalogued"); + }; + assert_eq!(r#type.to_string(), "Promoter"); + assert_eq!(identity, "https://synbiohub.org/public/igem/BBa_J23101"); + assert_eq!(doc.as_deref(), Some("constitutive promoter")); + + // The module publishes what it read, so a later `use` resolves it. + assert!(module.interface.exports.contains_key("BBa_J23101")); + assert!(module.interface.exports.contains_key("chlor")); + } + + /// The ordinary case for a registry: a part that publishes its sequence. + /// A kind that could not state one would reject almost every real part. + #[test] + fn an_atomic_part_carries_the_sequence_its_registry_publishes() { + let sequence = sbol3::Sequence::builder(NAMESPACE, "j23101_sequence") + .expect("valid") + .elements("TTGACAGCTAGCTCAGTCCTAGGTATAGTGCTAGC") + .encoding(sbol3::constants::EDAM_IUPAC_DNA) + .build() + .expect("complete"); + let component = sbol3::Component::builder(NAMESPACE, "BBa_J23101") + .expect("valid") + .add_type(sbol3::Iri::new(term("SBO:0000251")).expect("valid")) + .add_component_role(sbol3::Iri::new(term("SO:0000167")).expect("valid")) + .add_sequence(sequence.identity.clone()) + .build() + .expect("complete"); + let document = Document::from_objects(vec![ + SbolObject::Sequence(sequence), + SbolObject::Component(component), + ]) + .expect("distinct identities"); + + let (module, skipped) = check(&document); + assert!(skipped.is_empty(), "{skipped:?}"); + let CheckedDeclaration::Catalog { properties, .. } = catalogued(&module, "BBa_J23101") + else { + panic!("catalogued"); + }; + let names: Vec<&str> = properties.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, vec!["identity", "sequence"]); + } + + /// The point of building declarations rather than checked IR: the checker + /// is what decides a design is well formed, so a reader cannot produce + /// something an author could not have written. + #[test] + fn the_checker_rejects_a_property_no_schema_declares() { + let document = document(&[("chlor", &[&term("SBO:0000247")], None)]); + let mut read = read_designs(&document, &kinds()); + let Item::Artifact(declaration) = &mut read.module.items[0] else { + panic!("an artifact"); + }; + declaration + .members + .push(property("sequence", string("ACGT"))); + read.module.items.insert( + 0, + Item::Use(UseDecl { + path: path("std.bio.designs"), + span: Span::new(0, 0), + }), + ); + + let error = lab_language::compile_ast_module( + ModuleId::new("registry"), + &environment(), + &read.module, + ) + .expect_err("an antibiotic declares no sequence"); + assert!(format!("{error}").contains("sequence"), "{error}"); + } + + /// A composite: four parts joined head to tail plus the sequence the + /// assembly produces, checked against the plasmid schema. + #[test] + fn a_composite_becomes_ordered_components_and_a_sequence() { + let (module, skipped) = check(&transcription_unit()); + assert!(skipped.is_empty(), "{skipped:?}"); + + let CheckedDeclaration::Catalog { properties, .. } = catalogued(&module, "tu") else { + panic!("catalogued"); + }; + let names: Vec<&str> = properties.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, vec!["identity", "sequence", "components"]); + + let components = properties + .iter() + .find(|property| property.name == "components") + .expect("the composite states what it is built from"); + let lab_language::CheckedExpression::List { elements } = &components.value.value else { + panic!("components are a list"); + }; + let parts: Vec<&str> = elements + .iter() + .map(|element| match &element.value { + lab_language::CheckedExpression::Reference { path, .. } => path[0].as_str(), + other => panic!("a component is a reference, found {other:?}"), + }) + .collect(); + // Document order is deliberately not this order: the `meets` chain is. + assert_eq!( + parts, + vec!["p_j23101", "rbs_b0034", "cds_gfp", "term_b0015"] + ); + } + + /// A chain that closes into a ring says no part is first, and there is no + /// honest way to choose one. Reporting beats linearizing arbitrarily, + /// because the wrong order silently builds the wrong construct. + #[test] + fn a_cyclic_chain_is_reported_rather_than_linearized() { + let mut objects = transcription_unit_objects(); + objects.push(SbolObject::Constraint( + sbol3::Constraint::builder(&resource(&format!("{NAMESPACE}/tu")), "closing") + .expect("valid") + .subject(resource(&format!("{NAMESPACE}/tu/f0"))) + .constrained_object(resource(&format!("{NAMESPACE}/tu/f3"))) + .restriction(sbol3::Iri::from_static(SBOL_MEETS)) + .build() + .expect("complete"), + )); + let document = Document::from_objects(objects).expect("distinct identities"); + + let (_, skipped) = check(&document); + let refused = skipped + .iter() + .find(|skipped| skipped.identity.ends_with("/tu")) + .expect("the composite is refused"); + assert!( + matches!(refused.reason, ReadError::Unorderable { .. }), + "{:?}", + refused.reason + ); + } + + /// One unreadable component does not cost the rest of the document. + #[test] + fn an_unreadable_component_is_skipped_rather_than_failing_the_document() { + let document = document(&[ + ("readable", &[&term("SBO:0000247")], None), + ("mystery", &[&term("SO:0000694")], None), + ]); + let (module, skipped) = check(&document); + assert_eq!(module.declarations.len(), 1); + assert_eq!(skipped.len(), 1); + assert!(matches!(skipped[0].reason, ReadError::UnknownKind { .. })); + } + + /// Terms cannot separate a backbone from a plasmid, and the reader says so + /// with both candidates named rather than choosing one. + #[test] + fn an_ambiguous_component_names_its_candidates() { + let document = document(&[("pSB1C3", &[&term("SBO:0000251"), &term("SO:0000804")], None)]); + let (_, skipped) = check(&document); + let message = skipped[0].reason.to_string(); + assert!(message.contains("Backbone or a Plasmid"), "{message}"); + } + + /// A document Lab wrote states its own kind, so reading one back recovers + /// what the author meant instead of re-deriving it from terms that cannot + /// carry the distinction. + #[test] + fn a_stated_lab_kind_settles_what_terms_cannot() { + let component = sbol3::Component::builder(NAMESPACE, "pSB1C3") + .expect("valid") + .add_type(sbol3::Iri::new(term("SBO:0000251")).expect("valid")) + .add_type(sbol3::Iri::new(term("SO:0000804")).expect("valid")) + .extension( + sbol3::Iri::new(LAB_KIND).expect("valid"), + Term::Literal(sbol3::Literal::simple("Backbone")), + ) + .build() + .expect("complete"); + let document = + Document::from_objects(vec![SbolObject::Component(component)]).expect("one object"); + + let (module, skipped) = check(&document); + assert!(skipped.is_empty(), "{skipped:?}"); + let CheckedDeclaration::Catalog { r#type, .. } = catalogued(&module, "pSB1C3") else { + panic!("catalogued"); + }; + assert_eq!(r#type.to_string(), "Backbone"); + } + + /// Reading real SBOL, not just objects built in memory. + #[test] + fn reads_a_serialized_document() { + let turtle = transcription_unit() + .write(RdfFormat::Turtle) + .expect("the document serializes"); + let parsed = Document::read(&turtle, RdfFormat::Turtle).expect("it parses back"); + let (module, skipped) = check(&parsed); + assert!(skipped.is_empty(), "{skipped:?}"); + assert!(module.interface.exports.contains_key("tu")); + } +} diff --git a/docs/language/README.md b/docs/language/README.md index 3d026e5..c0d5cdf 100644 --- a/docs/language/README.md +++ b/docs/language/README.md @@ -9,6 +9,7 @@ The documents have distinct jobs: - `generics.md` records how type parameters, roles, generic kinds, and unit types fit together; - `modules.md` records package imports and idiomatic source organization; - `open-questions.md` keeps unresolved design choices visible; +- `sbol.md` explores how the SBOL standard would reach through the language and the compiler; - `support.md` records how far each feature has progressed through the compiler; - `decisions/` records design decisions and their status; - `specimens/` contains representative programs used to test the design. @@ -84,4 +85,5 @@ The latest accepted design records are: - [`0035`](decisions/0035-facility-files.md): facilities live outside package manifests; - [`0036`](decisions/0036-photoreal-projections.md): renderers play the shared scene and trace; - [`0037`](decisions/0037-robot-learning-is-a-physics-projection.md): reviewed handoffs project into robot-learning tasks while physics stays in simulator bindings; and -- [`0038`](decisions/0038-c3-is-the-primary-compute-provider.md): C3 is the primary finite-job compute provider behind a provider-neutral lifecycle and artifact boundary. +- [`0038`](decisions/0038-c3-is-the-primary-compute-provider.md): C3 is the primary finite-job compute provider behind a provider-neutral lifecycle and artifact boundary; and +- [`0039`](decisions/0039-roles-carry-ontology-terms.md): a role may name the ontology term it stands for. diff --git a/docs/language/decisions/0039-roles-carry-ontology-terms.md b/docs/language/decisions/0039-roles-carry-ontology-terms.md new file mode 100644 index 0000000..b374fac --- /dev/null +++ b/docs/language/decisions/0039-roles-carry-ontology-terms.md @@ -0,0 +1,109 @@ +# 0039 — A role may name the ontology term it stands for + +## Status + +Accepted. + +## Context + +The compiler knows more about a design than it can say. It knows `J23101` is a +promoter, that `chloramphenicol` is a selection agent rather than a nucleic +acid, and that a plasmid is an ordered composition of parts. None of that leaves +the toolchain in a vocabulary anyone else reads. + +The cost is visible wherever Lab meets another tool. An exporter that must state +what a named item *is* has nothing to consult, so it states the same +general-purpose term for every item and the distinction the checker was holding +is lost on the way out. + +The missing piece is small: a total function from a Lab type to the terms it +stands for. Everything else that makes Lab interoperable is downstream of it. + +## Decision + +A role may name the ontology term it stands for, and a kind may play roles. + +```lab +role NucleicAcid = "SBO:0000251" +role EngineeredRegion = "SO:0000804" + +artifact Plasmid is NucleicAcid, EngineeredRegion: + sequence?: DNA +``` + +Grounding is ordinary role membership. A role already classifies types, +membership already travels with the type that declares it, and a package may +already classify its own types against a role it imported. Naming a term adds +an identity to a mechanism that had none; it does not add a mechanism. + +A role's whole content is its identity, so the term is written after `=` rather +than as a property in a block. This is the form +[0021](0021-typed-external-identities.md) first proposed for catalogued items, +which is right here for the reason it was wrong there: a role has nothing else +to state. + +The `is` clause is the one records already use, and it classifies the type a +kind produces, because that is the type a workflow names and a bound reads. No +package introduces a grammar production, so +[0022](0022-fixed-grammar-open-vocabulary.md) holds: a chemistry package grounds +its kinds in ChEBI without the compiler learning chemistry. + +An ontology-grounded role is an ordinary open role and not a law. Nothing about +it is enforced by a bespoke rule, which is what keeps +[0020](0020-laws-are-declared-roles.md)'s closed set closed. + +A compact identifier expands to its `identifiers.org` IRI when it is checked, so +`SO:0000167` and `https://identifiers.org/SO:0000167` are one term written two +ways rather than two terms that happen to agree. + +## Consequences + +`std.bio.ontology` names the SBO, SO, and EDAM terms a synthetic-biology design +is described in, and `std.bio.designs` grounds every kind it declares. A program +that imports the standard library describes its plasmids in a shared vocabulary +without stating anything about ontologies itself. + +`Grounding` answers the question the rest of the work depends on: given a type, +which terms does it stand for. It is built over the modules in scope rather than +one module, because the role usually comes from a vocabulary package and the +membership from a design package, and neither knows the whole answer alone. + +A grounded role is usable as a bound, and that turned out to matter more than +expected. `Plasmid.components` was `List`, which admitted +neither a promoter nor a coding sequence even though an assembly joins those as +readily as it joins a bare part. Enumerating the admissible kinds would need +editing every time a package adds one. The field is now `List`, +bounded by a role introduced so that designs could be described in a shared +vocabulary. A term added for the sake of what a design *is* turned out to be the +right statement of what a design may be *built from*. + +Roles and types share one namespace, so a role cannot take a name a kind already +has. `role Promoter` collides with `artifact Promoter`. The vocabulary therefore +names the region rather than the part: `PromoterRegion`, `CodingSequence`, +`RibosomeEntrySite`. Splitting the namespace would cost more than the naming +does. + +Whether a term *exists* is not checked here. The frontend checks that a term +could name one — an absolute IRI, or a compact identifier with a recognized +prefix — and reports what it expected where the term is written: + +``` +error: 'engineered region' is neither an IRI nor a compact identifier + = help: write a term as "SO:0000167" or as the IRI it stands for + = help: a role with no term classifies types without naming any ontology +``` + +Membership, branch, and conflict checks need an ontology snapshot and stay +outside `lab-language`. The obvious move, depending on `sbol-ontology` from the +frontend, is wrong: that crate depends unconditionally on a CLI argument parser +and an HTTP stack, and `lab-language` is what `lab-ide-wasm` builds on. Keeping +the split also means a single file can be checked for a malformed term without +resolving a package. + +A term from a vocabulary this compiler has never heard of is written as a full +IRI and accepted. Recognizing a prefix is a convenience, not a gate. + +The portable module schema is `lab.portable-module.v4`. A consumer that ignores +the new fields reads a design with nothing said about what it is, which is the +silence this decision exists to end, so they raise the version rather than +riding along as optional additions. diff --git a/docs/language/open-questions.md b/docs/language/open-questions.md index d055f60..10221bd 100644 --- a/docs/language/open-questions.md +++ b/docs/language/open-questions.md @@ -24,7 +24,9 @@ A standard module written in Lab can declare roles, membership, data types, arti ## Parts and biological catalogs -A catalogued item is declared with `buy` against an imported kind, states the fields of its type, and names its own type where its kind is generic — `buy promoter pTet: Promoter` — so the biological catalog is written in Lab. This is not yet authoring syntax for declaring a part's sequence, provenance chain, version, or relationship to SBOL. It remains open how biological catalogs expose those richer declarations without reducing them to untyped properties or compiling changing catalog contents into `std`. +A catalogued item is declared with `buy` against an imported kind, states the fields of its type, and names its own type where its kind is generic — `buy promoter pTet: Promoter` — so the biological catalog is written in Lab. + +What a kind *is* now travels with it: a role may name an ontology term and a kind plays roles, so `Plasmid` states that it is a nucleic acid and an engineered region ([`0039`](decisions/0039-roles-carry-ontology-terms.md)). What remains open is the item rather than the kind. There is still no authoring syntax for a part's sequence, its provenance chain, or its version, and a catalogued item's `identity` is an opaque string that does not distinguish a resolvable registry record from a supplier's order number. It remains open how biological catalogs expose those richer declarations without reducing them to untyped properties or compiling changing catalog contents into `std`. The intended direction is recorded in [`sbol.md`](sbol.md). ## Target contracts diff --git a/docs/language/sbol.md b/docs/language/sbol.md new file mode 100644 index 0000000..d9267f7 --- /dev/null +++ b/docs/language/sbol.md @@ -0,0 +1,1274 @@ +# SBOL in Lab + +Ontology grounding is implemented, and so are the first four rungs of reading +designs from SBOL: recognizing a kind from its terms, atomic designs, composite +designs, and a checked module from a document. The rest is a design under +construction. Each section says which it is. + +The settled shape: **a laboratory writes its designs in Lab or in SBOL, and its +workflows in Lab.** The section on what can be written in SBOL explains why that +line falls where it does. + +## The problem + +Lab knows more about a design than it can say. The checker knows `J23101` is a +promoter that answers to a signal, that `chloramphenicol` is a selection agent +rather than a nucleic acid, that `composite_plasmid_1` is an ordered composition +of four parts in a backbone, and that two strains built by separate +transformations are independent biological entities. None of that leaves the +compiler in a vocabulary anyone else reads. + +Two spikes bracket the gap. + +`marpaia/labop` puts a standard *below* Lab as an export target. Its own header +states that information flows one way "into a weaker representation", and it +ships an `omissions.md` enumerating what it dropped: no loop construct, no +material linearity, no lineage. Two of Lab's central claims, affine material +flow ([0006](decisions/0006-affine-material-flow.md)) and replicate class as +lineage ([0026](decisions/0026-lineage-and-replicates.md)), cannot survive the +projection. Every emitted `sbol:Component` is typed `SBO:0000241`, the +general-purpose functional entity term, with the comment "the build does not +know whether a named item is DNA or a reagent". + +The build does know. It has no way to say so. + +`marpaia/python` puts a foreign host language *above* Lab. Python objects render +Lab source text downward and receive serialized `CheckedModule` JSON upward. The +type information is flattened to display strings on the way out and to +`dict[str, Any]` on the way in. The branch needs 134 lines of source-map +machinery for one reason: errors are reported against generated text the author +never wrote. + +Both treat a standard as a format at an edge. Neither makes it a participant in +the type system. `docs/language/open-questions.md` already names the +consequence: authoring syntax for a part's sequence, provenance chain, version, +and relationship to SBOL is missing, and the open question is how catalogs +expose those "without reducing them to untyped properties". + +## The thesis + +SBOL 3 and Lab's type system are the same model at two altitudes. Lab states a +design in terms a person writes; SBOL states it in terms a machine resolves. The +correspondence is close enough that the compiler can move in both directions, +and where the correspondence is exact it produces new compile-time checks rather +than new output formats. + +So the work is not "add an SBOL backend". It is: give the type system a way to +name ontology terms, and then every layer of the compiler has something to say. + +`sbol-rs` 1.0.0 is published on crates.io, is edition 2024 with MSRV 1.93 +against Lab's 1.95, has no feature flags to reason about, forbids unsafe, and +embeds its ontology facts as a pinned TSV with no runtime network access. It is +a dependency Lab can take without compromising hermetic builds. + +## Layer 0: ontology grounding + +**Implemented.** + +Everything else depends on one function: given a Lab type, what ontology terms +does it stand for. + +Lab already had the mechanism for attaching meaning to a type without touching +the parser. A role classifies types, membership travels with the type that +declares it, and a package may classify its own types against a role it +imported. What roles lacked was any external identity. They have one now: + +```lab +// std.bio.ontology +role NucleicAcid = "SBO:0000251" +role SimpleChemical = "SBO:0000247" +role EngineeredRegion = "SO:0000804" +role PromoterRegion = "SO:0000167" +role CodingSequence = "SO:0000316" +``` + +Grounding a kind is ordinary role membership: + +```lab +// std.bio.designs +use std.bio.ontology + +artifact Antibiotic is SimpleChemical +artifact Promoter is NucleicAcid, PromoterRegion + +artifact Plasmid is NucleicAcid, EngineeredRegion: + sequence?: DNA + backbone?: Backbone + components?: List +``` + +Two frontend changes, no new production. `parse_role` gained an optional +`= "term"` tail, in the shape +[0021](decisions/0021-typed-external-identities.md) first proposed for catalog +items. `parse_artifact_kind` gained a call to `parse_roles_clause`, which +already existed for records and already returned empty when `is` is absent. A +kind names the type its instances have, so the clause classifies that type and +lands in the existing role table beside every other membership. + +No package introduces a grammar production, so +[0022](decisions/0022-fixed-grammar-open-vocabulary.md) holds. A chemistry +package grounds its own kinds in ChEBI without the compiler learning chemistry. + +The alternative was to model terms as contributed schema properties under +[0028](decisions/0028-schemas-are-contributed-to.md), which needs no parser +change at all. It is worse: grounding belongs to a kind, and a property forces +every instance to restate that a plasmid is DNA. The altitude is wrong. + +Note the constraint from [0020](decisions/0020-laws-are-declared-roles.md): laws +are a closed set with no source form, deliberately. An ontology-grounded role is +an ordinary open role, not a law. Nothing about it is enforced by a bespoke +compiler rule; what the compiler does with it is read it. + +### Two things the implementation settled + +**Roles and types share one namespace**, so a role cannot be named after a kind +that already exists. `role Promoter` collides with `artifact Promoter`, and the +collision is reported as "'Promoter' is already a type". The vocabulary +therefore names the region rather than the part: `PromoterRegion`, +`CodingSequence`, `RibosomeEntrySite`. This is a naming cost, not a design flaw, +and it is worth paying rather than splitting the namespace. + +**Compact identifiers expand on the way in.** `SO:0000167` and +`https://identifiers.org/SO:0000167` are one term written two ways, so the +checker stores the expanded IRI and a test asserts the two spellings agree. +Otherwise a type grounded one way would silently fail to match a document +written the other. + +### Where the term is checked + +Term *shape* is checked in `lab-language`, at the line the term is written on, +with no dependencies: a term is an absolute IRI or a compact identifier with a +recognized prefix, and anything else is a diagnostic naming what was expected. A +term from a vocabulary the compiler has never heard of is written as a full IRI +and accepted, which keeps the mechanism open. + +Term *meaning* stays outside the frontend. The obvious move is to put +`sbol-ontology` in `lab-language`, and it is wrong: that crate depends +unconditionally on `clap` and `ureq`, which is a CLI parser and an HTTP stack in +the crate `lab-ide-wasm` builds on. Membership, branch, and conflict checks +therefore belong in `lab-sbol`, which is also where the document they validate +is built. Feature-gating the ontology cache path so the bundled facts are usable +alone is upstream work worth doing. + +The split has a second benefit: a single file can be checked for a malformed +term without resolving a package, and the questions that genuinely need a whole +program are asked when there is one. + +### What it looks like + +``` +error: 'engineered region' is neither an IRI nor a compact identifier + = help: write a term as "SO:0000167" or as the IRI it stands for + = help: a role with no term classifies types without naming any ontology +``` + +## Layer 1: identity that resolves + +Today `CheckedDeclaration::Catalog` carries `identity: String`, defaulting to +the declared name, and `source_lowering.rs` is its only consumer, reading it +into a `BTreeMap`. It is an opaque string: no scheme, no +namespace, no resolution, no version. + +Two changes. + +**A package declares its namespace.** This is `sbol3::Namespace`, validated by +rule sbol3-10301. + +```toml +[package] +name = "golden-gate" +version = "0.1.0" +namespace = "https://synbiohub.org/user/marpaia/golden-gate" +``` + +Every declaration mints a stable identity `{namespace}/{displayId}`. The +displayId encoder from `marpaia/labop`'s `identity.rs` moves out from under +`backend/` into the frontend where it belongs. It already solves the collision +that `sbol3::design::sanitize_display_id` gets wrong, keeping `pUC19-A` and +`pUC19_A` distinct, and carries a test asserting the encoding still satisfies +rule sbol3-10201. + +**An identity distinguishes a registry record from an order line.** Both are +written the same way, as the property they already are: + +```lab +buy: + part J23101: + identity = "https://synbiohub.org/public/igem/BBa_J23101/1" + + restriction_enzyme BsaI: + identity = "NEB-R0535" + digest_temperature = 37 C +``` + +An absolute IRI is resolvable and carries a design. A catalog number names +something to order. The compiler treats them differently because they mean +different things, which is the distinction +[0021](decisions/0021-typed-external-identities.md) collapsed. + +Where an identity resolves, the local declaration is checkable against the +registry record. A part declared `Promoter` whose SynBioHub record +carries `SO:0000316` is a diagnostic. This is a genuinely new class of error and +it is one biologists actually make. + +Resolution stays hermetic. Fetching is an explicit step that writes a vendored, +hash-locked SBOL document into the project, exactly as `lab.lock` does for +packages. `sbol3` ships `CachingHttpResolver` and `FileResolver`; the compiler +proper reads only the vendored file. `lab.lock` already carries a +`[packages.source] kind` discriminator with room for another kind. + +This also addresses the failure mode [0021](decisions/0021-typed-external-identities.md) +records in its Consequences, where a renamed symbol silently turned "use stock" +into "build it". An identity that resolves cannot be renamed into nothing. + +## Layer 2: sequences stop being opaque + +`DNA` is a nominal type with one constructor, `dna(String)`, and no structure. +The design dialect's verifier checks that a sequence is non-empty, uppercase, +and `ACGT` only. There are no IUPAC codes, no coordinates, no features, no +strand, no circular origin. + +Backed by `sbol3::Sequence` and `sbol_utilities::compute_sequence`, three things +move to compile time. + +**A composite's sequence is derived, not restated.** `composite_plasmid_1` +currently states a full `sequence` *and* `components = [J23101, B0034, GFP, B0015]`, +with nothing checking they agree. `compute_sequence` builds a sequence from +ordered sub-components chained head-to-tail with `meets` constraints. So a +design stating both must have them match, and a design stating only components +gets its sequence computed. The disjunction in + +```lab +declares sequence or (backbone and components) or (backbone and cargo) +``` + +stops hiding a possible inconsistency and becomes a real derivation. + +Two constraints to design around. `compute_sequence` requires the features to +form exactly one unambiguous linear chain of `meets` constraints, with a single +head and full coverage, and each part must resolve to a Component carrying +exactly one sequence; anything else is rejected. That is a fine fit for an +ordered `layout:`, and a poor fit for anything branching. + +More importantly, SBOL locates features with `Range { start, end }` validated as +`end >= start` and `end <= length`. Topology is only a type term, and no +coordinate machinery is aware of it. So a feature spanning the origin of a +circular sequence is a hard validation error, not a representable thing. Every +plasmid in the examples is circular, and `require topology == circular` is +written on each one, so this will be met early rather than as an edge case. + +**Restriction sites are counted.** `docs/language/specimens` and the extended +example already write + +```lab +require sites(BsaI) == 0 +``` + +and `sites(RestrictionEnzyme) -> Integer` is declared in the prelude with no +implementation that reads a sequence. A Golden Gate design with an internal BsaI +site is a bench failure the language already knows how to describe and the +compiler cannot yet evaluate. + +Real sequences make it evaluable, but not for free: sbol-rs has no subsequence +search, no reverse complement, and no restriction-site machinery. What SBOL +supplies is the sequence, its encoding, and the enzyme's identity; the search is +Lab's to write, including the two cases that make it non-trivial, matching the +reverse strand and matching across the origin of a circular topology. That is a +small, well-specified piece of work, and a candidate to contribute upstream +rather than keep private. + +**External sequences become input.** `sbol-genbank` and `sbol-fasta` mean a +plasmid can name a `.gb` file and the compiler reads its features into +sub-components. An existing GenBank library becomes Lab input without retyping. + +## Layer 3: circuits are interaction graphs + +This is the deepest correspondence, and the one that makes SBOL output +scientifically interesting rather than merely structurally complete. + +```lab +circuit regulated_expression( + promoter: Promoter, + coding: CDS, +) -> Circuit: + layout: + promoter + B0034 + coding + B0015 +``` + +The type parameters are not decoration. `Promoter` says this +promoter responds to tetracycline. `CDS` says this +coding sequence produces GFP. Those are SBOL Interactions: + +| Lab | SBOL 3 | +| --- | --- | +| `circuit ... layout:` | `Component` roled `SO:0000804`, ordered `SubComponent`s joined by `meets` `Constraint`s | +| `Promoter` | `Interaction` typed `SBO:0000170` (stimulation); `Participation` of `S` as `SBO:0000459` (stimulator), promoter as `SBO:0000598` | +| `CDS

` | `Interaction` typed `SBO:0000589` (genetic production); CDS as `SBO:0000645` (template), `P` as `SBO:0000011` (product) | +| `Circuit` | the composite's `Interface`: `S` an input, `P` an output | +| `any Signal` | a `VariableFeature`, or a `Collection` of variants | + +`sbol3::constants` already exports every one of those IRIs as a zero-cost +`Iri::from_static`. + +Two consequences. + +**Export.** Lab designs land in SynBioHub as queryable regulatory networks +rather than opaque blobs. The ecosystem compatibility is earned by the type +system instead of bolted on beside it. + +**Import.** The inverse is a type inference problem the compiler can solve. +Given a Component with a genetic-production Interaction whose product is a GFP +Component, infer `CDS`. `lab import ` turns a +registry part into a typed Lab declaration a person can read. + +A convenient accident: `layout:` is parsed, type-checked, and preserved into the +checked IR as `CheckedSection`, and nothing downstream reads it. An artifact-level +section is explicitly rejected with "section has no semantics yet". The ordered +structure SBOL needs is already there, already checked, and currently inert. + +One limit to plan around: `sbol3::design::Design`, the arena, mints only +Component, Sequence, SubComponent, and Constraint. Interactions, Participations, +and Interfaces need `Interaction::builder(...)` and a single +`Document::from_objects` at the end. The emitter is the arena for structure plus +direct builders for the functional layer. + +## Layer 4: provenance is PROV-O + +This is the layer that argues SBOL rather than LabOP is the right foundation. +`marpaia/labop` had to write "LabOP records no lineage" and "material linearity +is not represented" into an omissions report. SBOL 3 includes PROV-O natively, +and `sbol-rs` models it as first-class typed classes: `Activity`, `Agent`, +`Plan`, `Association`, `Usage`, with `wasDerivedFrom` and `wasGeneratedBy` on +every Identified. + +The correspondence with `provenance.rs` is exact: + +| Lab | SBOL 3 and PROV-O | +| --- | --- | +| `build plasmid p`, the design | `Component` | +| the realized material | `Implementation` with `built -> Component` | +| `Origin(usize)`, a lineage beginning | `prov:Activity` | +| `Provenance::From({o})` | `prov:wasGeneratedBy -> o` | +| `Provenance::From({a, b})` | two `wasGeneratedBy`, which is how a reader recomputes independence | +| `EachFrom(o)`, the colonies of one pick | one Activity generating a `Collection` of Implementations | +| `across 3 biological replicates` | three Implementations with distinct `wasGeneratedBy` | +| `accept concentration >= 100 ng/uL` | `ExperimentalData` and an OM `Measure`, gathered in an `Experiment` | +| the workflow that built it | `prov:Plan`, with `Association.hadRole = DBTL_BUILD` | +| the target profile and instrument | `prov:Agent` | + +The property that matters: **independence survives the round trip.** A third +party reading Lab's output can recompute which samples are biological replicates +and which are technical, because `wasGeneratedBy` carries exactly what `Origin` +carries. Lab's headline scientific claim becomes checkable by someone else's +tool, on a published document, in a standard vocabulary. Nothing else in the +ecosystem publishes that. The pseudo-replication check stops being a property of +one compiler and becomes a property of the artifact. + +## Layer 5: combinatorial designs get a form + +`examples/golden-gate` is a two-by-two design, two promoters against two chassis, +written as four near-identical plasmid declarations, four near-identical strain +declarations, four near-identical workflows, and four lines of `main`. Roughly +two hundred lines describing four points in a design space that is never stated. + +SBOL has exactly this. `CombinatorialDerivation` with `VariableFeature`s +carrying cardinality, and `sbol_utilities::expand_derivations` to expand it. + +A Lab form needs no grammar production, because a pure function in a property is +already how the language composes values: + +```lab +build panel reporter_panel: + template = composite_plasmid + variants = [ + vary(promoter, [J23101, J23106]), + vary(chassis, [DH5alpha, BL21]), + ] +``` + +That lowers to a `CombinatorialDerivation`, expands before planning, and the +expansion is what the build graph sees. The compiler reports the panel is four +strains before anyone pipettes, and the published document states the design +space rather than four accidents that happen to resemble each other. + +`expand_derivation` currently rejects templates carrying interactions or +interfaces, so circuit templates are out at first. Since sbol-rs is maintained +in-house that is a contribution rather than a wall. + +## How deep this goes + +The layers above put SBOL at the frontend and at emission. The question worth +asking is whether it belongs in the middle too, and the answer is yes in five +places and no in three. The line between them is not arbitrary: + +> SBOL owns what a thing is and where it came from. Lab owns what may be done +> with it, in what order, and on what evidence. + +Every case for going deeper sits on the first side of that line. Every case +against sits on the second. This is why the two compose rather than compete: +SBOL cannot express affine material flow or refuse pseudo-replication, and Lab +should not be reinventing sequence identity or the Sequence Ontology. + +### Identity, from the checker onward + +This is the deepest change and the one the rest depend on. + +Lab already agrees that identity is a pair of scope and name: + +```rust +pub struct ModuleId(String); + +pub struct DefinitionId { + pub module: ModuleId, + pub local: String, +} +``` + +That is the same shape as `sbol3::SbolIdentity { namespace: Namespace, display_id: DisplayId }`. +The difference is that one resolves and the other does not. + +The sharper finding is that this identity is already minted and already +threaded, and nothing consumes it. `CheckedExpression::Reference` carries a +`DefinitionId` beside its path; `ModuleExport` carries one for every export. +Both are written by the checker. No pass in `lab-compiler`, `lab-ide`, or +`lab-language-server` reads either, and the only references outside the checker +are in tests. Every real consumer takes `path.first()` and works with the bare +word instead. + +`DefinitionId::source`, the constructor meant for scoped declarations, is never +called at all. Its doc comment describes a scheme ("the declaration name plus +its byte offset so future scoped declarations cannot collide") that no code +uses. A byte offset is in any case a placeholder for a scope, and SBOL specifies +the thing it is approximating: a top-level object is `{namespace}/{displayId}`, +a child is `{parent}/{displayId}`, so a scoped declaration is a child of its +scope. `marpaia/labop`'s per-parent counter already implements that convention. + +So this is not a new identity concept and not even a new field. Lab designed a +structured identity, decided its shape, and then routed every consumer around it +through bare strings. The work is finishing the wiring and making the result +resolvable. What follows from it: + +- Editor navigation stops being lexical. `Workspace::definition` finds the first + declaration whose `name` string matches, and `Workspace::references` matches + identifier text across every open document with no scope or module awareness. + `rename` is built on `references`, so today a rename rewrites every + textually-identical identifier in every open file. The support matrix calls + this "name-based fallback pending symbol identities/scopes"; resolvable + identity is the thing it is pending on. +- The map from an SBOL object back to a source span becomes a byproduct of + lowering rather than separate bookkeeping, so any SBOL-side diagnostic can + point at source. +- A run record can name the same thing the design names, which is what + closes the loop below. +- A build becomes content-addressable. There is no digest API in sbol-rs, but + `normalized_triples()` sorts and dedups into a deterministic total order, + which is a usable canonical form as long as no blank nodes appear. Note that + `Graph::write` serializes the unsorted vector, so a digest has to sort first + and serialize itself rather than hashing `write(NTriples)`. +- Two imports may export the same word. Today they cannot: + `insert_imported_name` rejects a collision outright with "imported name 'x' is + ambiguous between 'a' and 'b'", because a bare word is the only identity + available to disambiguate with. That restriction is a symptom, not a policy, + and it gets worse as soon as third-party part catalogs are dependencies. + +One caution about scope. `LineageMap` keys on binding names within a workflow +body, and `material_flow.rs` tracks ownership as `Place(Vec)`, a dotted +access path. Those are *local* names, not declarations, and an IRI is the wrong +tool for them: what they want is SSA value identity, which LAIR already has. +Making declaration identity resolvable and leaving binding identity alone is the +correct split, and conflating the two would be the easiest way to make this +change much larger than it needs to be. + +### Design LAIR becomes an SBOL document + +The design dialect does not use pliron for anything pliron is good at. + +```rust +#[pliron_op( + name = "design.plasmid", + attributes = ( artifact_name: StringAttr, sequence: StringAttr, ... ), + interfaces = [NOpdsInterface<0>], + results = (design: DesignType) +)] +pub struct DesignPlasmidOp; +``` + +Zero operands. `design.plasmid` and `design.strain` define an opaque SSA value +and carry a flat attribute bag. The workflow dialect does consume that value, +`workflow.realize` declaring `operands = (design: DesignType)` and +`workflow.transform` declaring `operands = (design: DesignType, cells: MaterialType)`, +and those operands carry the use-def edges `MaterialLinearityAnalysis` walks to +enforce affinity. So the design layer is a source in the dataflow graph. + +But that one edge is not really a graph edge. It is rebuilt during lowering from +a string map: + +```rust +let mut designs = BTreeMap::new(); +// ... designs.insert(artifact.name().to_owned(), design); +let design = designs[&name]; +``` + +including the raw index, which panics rather than diagnoses if the name does not +match. And the edges that actually describe the design space are not SSA at all: +`realize_components`, `realize_dependencies`, and `strain_plasmids` are +`VecAttr` of `StringAttr`, and `backend/graph.rs` reconstructs the whole build +DAG downstream by string equality against the set of assembled artifact names. + +So a graph is being encoded as strings inside a system that already has a graph. +The rest of the design dialect is a hand-maintained re-encoding of what SBOL +specifies, including a bespoke verifier that checks a sequence is "non-empty, +uppercase, and unambiguous DNA". + +One concrete blocker to note before starting: both design ops verify +`pliron::identifier::Identifier::try_from(artifact_name)`, which constrains an +artifact name to a bare identifier and would reject any IRI. Carrying identity +into LAIR needs an attribute type for it rather than reuse of `StringAttr`. + +`IrStage::Design` is already a named, standalone, verifier-valid stage, so the +seam exists. Make that stage an `sbol3::Document` and reduce the pliron design +op to a reference carrying an IRI. Workflow and Protocol LAIR are untouched. + +Two payoffs beyond deleting code. The bespoke verifier is replaced by 109 +machine-checkable spec rules. And ADR 0022's unfinished half comes within reach: +`source_lowering.rs` currently matches `"plasmid"` and `"strain"` by name and +reads fields by name, but a Component carrying type and role terms is generic, +so the special-casing has somewhere to go. + +### SBOL validation as a checker pass + +Not an output gate. `compile_parsed_module` is two passes today: + +```rust +let checked = checker::check_module(module_id, environment, module)?; +material_flow::verify_module(&checked, environment)?; +``` + +An SBOL verification pass is a peer of the second, and belongs beside it for the +same stated reason: it "runs after semantic checking so it never has to +reinterpret source syntax". + +Rules carry stable string ids and a closed `Hint` enum, so rendering them is +mechanical rather than prose-scraping. But two shipped facts constrain where +this pass can run, and both need stating plainly because they contradict the +obvious design. + +**Rule selection does not work in SBOL 3 as shipped.** `ValidationConfig` +advertises `complete`, `compliant`, `types_in_uri`, and `keep_going`, and it +reads as though per-module checking could run with `complete: false` and the +program boundary with `complete: true`. It cannot. Not one of the 149 rules in +`crates/sbol3/rules.toml` carries a gate, so `complete: false` skips nothing; +`types_in_uri` and `keep_going` are never read on the SBOL 3 path at all. Only +`best_practice` has an effect, and by a different mechanism, suppression at emit +time. Per-rule `allow` is available but explicitly does not save work: the check +runs and the diagnostic is discarded. + +The machinery is real, just unwired here. `crates/sbol2/rules.toml` gates 70 +rules and its validator branches on them. Making the two-tier split work is an +upstream change, adding gates to the SBOL 3 catalog, not a Lab-side one. + +**There is no partial validation.** `Validator` is `pub(crate)`, every entry +point hangs off `Document`, and rules resolve references through the document. +So a validation pass must materialize a full `Document`, and +`Document::from_objects` costs roughly two deep copies: it lowers every object +to triples, then re-parses those triples back into a property-bag map. A +`Document` itself holds its data three times over, as triples, as a property +map, and as typed objects, with `Iri` backed by `Cow` rather than a refcount and +no interning anywhere. + +That is affordable once per module compile. It is not affordable per keystroke, +which is a second reason the pass belongs in `lab-project` rather than in the +path `lab-ide-wasm` drives. Cheap per-object checks that the editor can run +belong on `sbol_core::syntax` and the `DisplayId` and `Namespace` newtypes, +which validate at construction with no document at all. + +### A package can be an SBOL document + +`ModuleInterface` is serde, self-describing, and injected through one function: + +```rust +pub struct ModuleInterface { + pub module: ModuleId, + pub documentation: String, + pub exports: BTreeMap, +} + +pub fn insert(&mut self, visible_name: impl Into, interface: ModuleInterface) +``` + +Synthesizing one from an SBOL document is mechanical. A `Component` becomes a +`ModuleExport`; its `roles` map back to Lab role names through the same term +table Layer 0 establishes; its sequence and type become fields. + +This answers the open question directly. A parts catalog stops being Lab source +compiled into `std` and becomes a vendored, hash-locked SBOL document that a +registry can export and a project can depend on. `open-questions.md` asks how +catalogs expose sequences, provenance, and versions "without reducing them to +untyped properties or compiling changing catalog contents into `std`". This is +how. + +### The execution boundary becomes PROV-O + +This is the layer the earlier design did not reach, and the correspondence is +field for field. + +```rust +pub struct WorkcellNode { pub id: String, pub after: Vec, pub action: WorkcellAction } +pub struct LedgerEntry { pub node: String, pub event: LedgerEvent, pub at_unix_seconds: u64 } +pub enum LedgerEvent { Started, Confirmed, Completed, Failed } +``` + +against + +```rust +pub struct Activity { ..., pub started_at_time: Option, pub ended_at_time: Option, + pub was_informed_by: Vec, + pub qualified_usage: Vec, pub qualified_association: Vec } +pub struct Association { ..., pub agent: Option, pub had_role: Vec, pub had_plan: Option } +pub struct Usage { ..., pub entity: Option, pub had_role: Vec } +``` + +`after` is `wasInformedBy`. `Started` and `Completed` are `startedAtTime` and +`endedAtTime`. `Confirmed` is an `Association` naming the operator as an +`Agent`. A `WorkcellStation { name, kind }` is an `Agent`. The emitted plan is a +`Plan`. The ledger's own comment already describes what it is: "the run's memory +and its evidence". + +Today the design document and the run record are two files with no identity in +common. Keyed by the same IRIs, the chain from a design through the run that +executed it to the physical tube it produced is one graph. That is also where +`accept` claims finally put their runtime evidence, which `support.md` lists as +unresolved, and it is the point at which the emitted document stops being a +protocol and becomes a laboratory notebook. + +The shape maps; the resolution does not, and four gaps are worth knowing before +committing to this as the record format rather than an export of it. + +`Activity` carries one `started_at_time` and one `ended_at_time`, and neither +`Association` nor `Usage` has a time field. The ledger timestamps every event, +so a step-level timeline has nowhere standard to go; it fits only as one +Activity per node, or as extension triples. + +There is no `prov:generated` forward edge. Outputs are discoverable only by +scanning for objects whose `wasGeneratedBy` names the activity, and +`Document::resolve` is a linear scan, so this is quadratic on a large document +without an index of your own. + +`Agent` and `Plan` have no fields beyond the shared Identified and TopLevel +data. An instrument, an operator, and a piece of software are distinguishable +only by name, description, or extension predicates. There is also no delegation, +so "operator supervised instrument" is not expressible, and no attestation +concept at all, so `Confirmed` maps to a custom `hadRole` IRI and nothing +validates it. + +`hadRole` is checked against a closed four-value vocabulary, design, build, +test, and learn. Custom lab roles are neither accepted nor rejected, they are +skipped. And using the standard roles has teeth: a `test` entity is required to +be `ExperimentalData` and a `build` entity an `Implementation`. + +None of this blocks the work. It does mean the run record is Lab's format +carrying PROV-O structure, rather than PROV-O being the format, and the +extension triples that make up the difference should be designed deliberately +rather than accumulated. + +### Where it should not go + +**Workflow and Protocol IR as RDF.** Triples are an unordered set with no +use-def edges. `material_flow.rs` tracks ownership as `Place(Vec)` over +ordered statements, and `MaterialLinearityAnalysis` walks SSA use-lists. +`marpaia/labop` already ran this experiment and reported the result: Golden Gate +cycling written out one incubation at a time because "a LabOP activity has no +loop construct", and "material linearity is not represented" filed in its +omissions report. + +**`CheckedModule` holding sbol3 types.** There is no serde anywhere in sbol-rs, +and the portable module IR is versioned and must stay self-describing across +compiler versions. IRIs travel there as strings and objects are rebuilt at the +SBOL boundary. + +**Ontology-derived subtyping.** This one is not available, and the reason is +worth recording because the API makes it look available. + +Role membership is a flat, non-transitive lookup today: + +```rust +fn plays_role(roles: &RoleTable, actual: &Ty, role: &str) -> bool { + matches!(actual, Ty::Named(name, arguments) + if arguments.is_empty() + && roles.get(name).is_some_and(|played| played.contains(role))) +} +``` + +`sbol-ontology` exposes `is_descendant`, a genuine recursive walk over each +term's parents, so the natural design is to close +`type_roles: HashMap>` over the ontology once when the +semantic context is built, leaving `plays_role` untouched and confining the +ontology's influence to one construction step. + +The bundled snapshot will not support it. It carries 15,951 terms, of which +**106 are SO**, and the SO hierarchy in it is flattened: `SO:0000167` (promoter) +has a single parent, `SO:0000110` (sequence_feature). The real intermediate +terms are simply absent, so a descendant query against anything but +`SO:0000110` returns false, silently, because the ancestor is unknown. Promoter, +CDS, RBS, terminator, operator, and engineered region are all reparented the +same way. The snapshot is a validation aid for the terms the SBOL spec needs, +not a taxonomy. + +The extension path does not rescue it either. `Ontology::from_tsv_path` is +public and `build_extension_tsv` does preserve within-subtree parent chains, but +`parse_namespace` accepts a closed set of seven ontologies and `parse_role` a +closed set of seven SBOL-facing roles, so a `LAB:` namespace or a +compiler-specific term role is rejected outright. And `extend_with` uses +`or_insert`, so bundled facts win: a supplied full-fidelity SO cannot repair the +flattened parents. Forking the bundled TSV is the only local option, which is +the wrong shape for a dependency. + +So: ontology-derived subtyping is upstream work in `sbol-ontology`, a fuller SO +import that keeps real parent chains plus open namespace and role registration. +It is feasible, since sbol-rs is maintained in-house, but it is a separate piece +of work with its own risk and should not be assumed by anything on the Lab side. +Until then the ontology validates terms and does not classify types, which is +also the conservative place to start. + +Two details worth carrying forward whenever it does land. `has_ancestor` is +unmemoized and has no cycle guard, so a closure pass should compute its own +rather than call it per query. And if the type system ever does depend on the +snapshot, the snapshot becomes a dependency and belongs pinned in `lab.lock`; +`sbol-ontology` already carries a `TSV_FORMAT_VERSION` and per-source +`raw_sha256` to pin against. + +One detail worth copying rather than fighting: `terms_conflict` returns +`Option`, where `None` means one of the terms is not in the snapshot. That +is the same discipline `provenance.rs` applies when it refuses to guess, and a +check reading it should stay silent for the same reason. + +## Can a Lab program be written in SBOL? + +**Design under construction.** Three different questions hide in that one, and +they have different answers. + +### The three questions + +**Can SBOL be an authoring format for designs?** Yes, and this is where the +value is. A `Component` with a sequence, ordered sub-components, roles, and +types is exactly what `build plasmid` and `circuit ... layout:` say. Designs +authored in SynBioHub, Benchling, or a GenBank file become Lab declarations with +nothing invented. + +**Can a whole Lab program round-trip through SBOL?** Technically yes, by +defining `lab:Workflow`, `lab:Action`, and `lab:Requirement` as +`IdentifiedExtension` top-levels and hanging the rest off extension triples, +which sbol-rs preserves faithfully. What you get is Lab's AST encoded in RDF. No +other tool understands the workflow half, so you pay RDF's ergonomics for the +part SBOL already covered and gain nothing for the part it did not. + +**Can the whole language be authored in SBOL?** No, for the program half, and +the reason is a category difference rather than a missing feature. + +### Why the program half cannot move + +SBOL describes biological structure and history. As a language it is unordered, +has no binder, no expression language, no type variables, and no notion of a +value being consumed. Lab's program half is exactly those five things. + +The nearest thing SBOL has to a predicate is `Constraint.restriction`, and it is +a fixed sixteen-value vocabulary of spatial relations between features within +one component: `meets`, `precedes`, `contains`, `overlaps`, `sameOrientationAs` +and the rest. It relates two features. It cannot say `sites(BsaI) == 0` or +`concentration >= 100 ng/uL`, because there is nowhere in the model for an +operator, a function call, or a comparison to live. `CheckedExpression` has +eleven forms including `Call`, `Unary`, and `Binary`; SBOL has none of them. + +Generics are the sharpest case. `CombinatorialDerivation` with `VariableFeature` +looks like a type parameter and is not: it enumerates concrete variants for a +slot. `Promoter` is a function over types with a bound, and +`Circuit` is an existential that +deliberately forgets its witness while pinning the product. Neither has any +analogue, and neither is reachable by extension without inventing a type theory +in RDF. + +### Feature by feature + +| Lab construct | In SBOL | +| --- | --- | +| `artifact X is NucleicAcid, EngineeredRegion` | `Component.types` and `.roles`. Native, implemented | +| `build plasmid p: sequence, components` | `Component` + `Sequence` + `SubComponent` + `meets` `Constraint`s. Native | +| `buy part J23101` | `Component` at a registry identity. Native | +| `circuit ... layout:` (structure) | ordered `SubComponent`s. Native | +| `require topology == circular` | `SO:0000988` as a type term. Native, because topology *is* a term | +| a combinatorial panel | `CombinatorialDerivation` + `VariableFeature`. Native | +| lineage, replicate independence | `prov:wasGeneratedBy`. Native | +| `Quantity

    ` | OM `Measure` and `Unit`. Native, minus the missing constants | +| `record` with `case` constructors | extension only | +| `artifact` field schemas | extension only; SBOL has no schema language | +| `Part \| Plasmid` unions | extension only | +| `declares sequence or (backbone and components)` | extension only | +| `require` / `accept` predicates | extension only, as opaque text | +| `across 3 biological replicates` | extension only | +| `Promoter`, `any Signal` | **not expressible** | +| `workflow`, `x <- action` | **not expressible** | +| affine material flow | **not expressible**: no ordering, no use-def | +| `state`, `when every 30 min` | **not expressible** | +| `if`, `match`, `for`, `emit` | **not expressible** | + +The bottom group is not an accident, and it is worth saying plainly: those are +the features that justify Lab existing. If SBOL could express affine material +flow, lineage-based replicate class, and the type-level link between an inducer +and the circuit it induces, Lab would be a syntax for SBOL and little else. The +ceiling is real, and it is in the right place. + +### What is worth building instead + +Split the package rather than the language. **Designs move to SBOL; workflows +stay in Lab.** + +That is not a consolation prize. In `examples/golden-gate-extended` the designs +are 302 lines against 163 of workflows and 47 of program, so the design half is +the larger one, and it is also the half that varies between laboratories, the +half that already exists in registries, and the half SBOL covers natively. A +Lab package whose `designs/` directory is an SBOL document and whose +`workflows/` directory is Lab source is most of the way to the goal. + +Concretely, `use` resolves an SBOL document as a module. The rungs: + +1. **Recognizing a kind from terms.** *Implemented*, as `lab_sbol::KindIndex`. + `Grounding` maps a type to its terms; this inverts it, so a `Component` typed + `SBO:0000251` and roled `SO:0000167` is a `Promoter`. A candidate is a kind + whose every term the object also states, and among candidates one that + another strictly contains is discarded, so the most specific kind wins and an + object may be more specific than the vocabulary reading it. +2. **A reader for atomic designs.** *Implemented*, as `lab_sbol::read_designs`. + Every `Component` becomes a `CheckedDeclaration::Catalog` carrying the + registry's own IRI as its identity, so an import stays resolvable back to + where it came from. +3. **Composite designs.** *Implemented.* Sub-components become `components` and + `Sequence` objects become `sequence`, the same `std.bio.dna` call an author + writes, so a design read from a document and one written by hand reach a + backend identically. Each element carries the kind of the part it names, so a + plasmid used as a component is a `Plasmid` rather than silently a `Part`. +4. **A checked module from a document.** *Implemented*, as + `lab_sbol::read_module`. The reader builds declarations and hands them to the + checker rather than forging checked IR, so a design read from a document is + subject to every rule a design typed by hand is, with one implementation of + those rules and no second one to drift. +5. **Resolvable identity for the other direction**, so a Lab-authored design + mints an IRI rather than only consuming one. +6. **Interaction to type-argument inference.** An `Interaction` typed + `SBO:0000589` whose template is a CDS and whose product is a GFP component + yields `CDS`. This is the hard and interesting part, + because it is what recovers the type parameters Lab's checking runs on. It + will work for the standard patterns and will not be total, so it needs to + fail loudly rather than guess. +7. **Discovery.** *Implemented.* A `.ttl`, `.nt`, `.jsonld`, or `.rdf` file + under `src/` is discovered, named, ordered, and compiled the way a `.lab` + file is, so a package's designs can be written in either language. This is + the rung that delivers the goal end to end: `examples/golden-gate-extended` + now keeps its DNA parts in `src/designs/parts.ttl` and builds robot + protocols from them. + +### What the first two rungs settled + +**Terms under-determine the kind, and that is not a defect to engineer around.** +`Backbone` and `Plasmid` both ground as an engineered region of nucleic acid, +and the bundled Sequence Ontology snapshot has no `plasmid` or `vector` term to +separate them. Nor should SBOL have one: the difference is not biological, it is +which part each plays in an assembly, and the vector you cut open is a plasmid. +So a term set names one kind, several, or none, and the reader reports which: + +``` +'https://.../pSB1C3' is a Backbone or a Plasmid, and nothing in the document says which +``` + +A document Lab wrote settles it by stating `lab:kind` in Lab's own namespace, +which is honoured ahead of inference, so a round trip recovers what the author +meant instead of re-deriving it from terms that cannot carry the distinction. +Nothing else needs that predicate, and a third-party reader ignores it. + +**An imported design is catalogued, not built.** Whether a laboratory builds a +plasmid or orders it is a fact about that laboratory rather than about the +design ([0027](decisions/0027-provenance-is-stated-per-thing.md)), and a +registry has no opinion. Reading an import as `buy` is the honest default, +because a registry listing something is exactly the claim that you can obtain +it. + +**One unreadable component does not cost the document.** A registry export is +large and partly outside any one program's vocabulary, so components that cannot +be read are collected beside the ones that could and the caller decides which +problems are fatal. Refusing the whole file over one unrecognized term would +make the feature unusable against real registry data. + +**There is no residue channel, because widening the mapping is the better +answer.** An imported component states more than a Lab kind declares, and the +obvious response is a side-channel carrying whatever did not fit. The better one +is to make the kinds cover what SBOL actually specifies, so there is less that +does not fit. [0028](decisions/0028-schemas-are-contributed-to.md) is already +the mechanism: several packages describe one kind, so a module can contribute +the fields an SBOL statement needs without touching the kind that declared it. + +Most of the specified content maps once you look. A component's sub-components +are `components`; its sequence is `sequence`; its types and roles are grounding; +its measures are quantity properties; its name and description are +documentation. `meets` constraints and list order are the same fact stated twice, +so the chain is walked once on the way in and the coordinates it implies are +recomputed rather than carried. That is normalization, not loss. + +What genuinely cannot be covered by any fixed schema is the open-ended part: +annotations other tools attach in their own namespaces, `Attachment` files, and +`Model` references to external SBML. Those are unbounded by construction, and no +Lab kind can anticipate their union. So the question shrinks from "what do we do +with everything that did not fit" to "what do we do with third-party +annotations", which is a much smaller question and can be answered later without +blocking anything. + +The rule the reader holds meanwhile: **it emits no property a schema does not +declare.** That is not a discipline it keeps, it is a fact the checker enforces, +because the reader builds declarations and hands them to the checker rather than +forging checked IR. A test drives it by adding a `sequence` to an antibiotic and +asserting the compile fails. + +### What discovery settled + +**Imports are derived, not configured.** A document names the terms its +components stand for and never says which Lab package describes them, so +requiring a sidecar or a manifest key to state the imports would make every +registry export need a hand-written companion. Instead `Grounding` records which +module declares each kind, and a document imports the modules declaring exactly +the kinds it turned out to use. A registry export is readable as it comes, and a +document that grows a new kind of part needs nothing written for it. + +**A document depends on nothing inside its own package.** It states components +and terms, never a sibling module, so it is ready to compile before any Lab +module and the existing dependency ordering needs no special case. + +**`.xml` and `.json` are deliberately not recognized.** Either could be several +things, and guessing wrong produces a parse error that blames the document +rather than the guess. An SBOL document names its serialization in its +extension or it is not discovered. + +**Moving designs into SBOL changes what an order names**, and the compiler said +so before anything was built: + +``` +error: the manifest declares material 'B0015', which this build never uses; + a catalogued name that was renamed leaves its old identity here +``` + +A Lab `buy part B0015` defaults its identity to the symbol. The same part read +from SBOL carries the registry IRI it resolves to, and that IRI is what an order +names. The manifest's `[inventory]` list had to follow. That diagnostic is the +mitigation [0021](decisions/0021-typed-external-identities.md) added after a +rename silently turned "use stock" into "build it", and it caught a real +identity change on its first encounter with a second language. + +### Widening the mapping found three real modelling gaps + +Running the checker over what the reader built immediately rejected a composite: + +``` +plasmid property 'components' expects List, found List +``` + +That was correct, and the schema was wrong. A design is assembled from promoters +and coding sequences as readily as from bare parts, and `List` +admitted neither. Enumerating the kinds would have produced a list that every new +kind of part has to be added to, so the field is now: + +```lab +components?: List +``` + +The role that makes this expressible is the ontology grounding from Layer 0. A +term introduced so designs could be *exported* in a shared vocabulary turned out +to be the right bound for a Lab type, which is the clearest evidence so far that +the two models really are the same model at two altitudes. Hand-forged checked IR +would have accepted the bad list silently; the checker is what found it. + +The second gap was quieter and would have been worse. `Part`, `Promoter`, `CDS`, +and `Backbone` declared no `sequence` field at all, so importing any registry +part that publishes its sequence would have been rejected. That is nearly every +real part. Each of those kinds now declares `sequence?: DNA`; none of them +carried prelude fields before, so nothing was displaced. + +The third was a desync the widening caused rather than revealed. +`source_lowering.rs` reads components with a hardcoded +`&["Part", "Plasmid"]`, so a design with a promoter component would have checked +and then failed to lower with a generic invalid-field error. The golden-gate +examples use only bare parts, so no test would have caught it. The list is +widened to match, with a comment saying it has to track the schema. Naming +kinds where the grounding is already available is +[0022](decisions/0022-fixed-grammar-open-vocabulary.md)'s unfinished business +showing up in a third place. + +### The honest way to close the loop + +For the protocol half, do not pretend. An emitted SBOL document can carry the +workflow that produced it as an `Attachment`, with `source`, `format`, and a +`hash` alongside a `prov:Plan` that names it. The document then says "this +design was built by that protocol, and here is its digest" without claiming RDF +understands the protocol. That is cheap, true, and enough for provenance. + +Attempting more is what produced `marpaia/labop`'s omissions report. + +## Where it lands in the compiler + +The workspace gains one crate. `lab-compiler/README.md` records the invariant +that no production backend imports `lab-language`, and this respects it. + +**`lab-language`** gains only Layer 0: the identity tail on `role`, a resolver +from `Ty` to a set of term IRIs, and ontology-validity diagnostics. Its only new +dependency is `sbol-ontology`, which is an embedded TSV with no RDF and no +network. The crate stays I/O-free and stays light enough for `lab-ide-wasm`. + +**`lab-sbol`** is new, sits beside `lab-language`, and owns the correspondence: +identity minting, `CheckedModule` to `sbol3::Document`, `sbol3::Document` to Lab +declarations for import, and `ValidationReport` to Lab diagnostics. Depends on +`sbol = "1"`. + +**`lab-compiler`** gains an SBOL emitter producing an `ArtifactBundle`, and the +design stage gains an SBOL representation. + +Placement note for the validation pass. It is a peer of +`material_flow::verify_module` in kind, but putting it inside +`compile_parsed_module` would pull `oxrdf` and its dependencies into the crate +the wasm editor builds on. Running it from `lab-project::compile` and from +`labc` instead keeps the frontend light, at the cost that a bare +`compile_module` call does not run it. That is the right trade while the editor +target exists. + +### The IR change, shallow and deep + +Two versions, and they are a sequence rather than a choice. + +**Shallow.** `design.plasmid` today carries `artifact_name`, `sequence`, +`topology`, `copies`, and two acceptance thresholds, all as `StringAttr` or +`IntegerAttr`. It gains an identity IRI, type and role term IRIs, and an +encoding term on the sequence. That alone is what lets a backend stop guessing +`SBO:0000241`, and it is a small enough change to land early. + +**Deep.** Once terms are attributes rather than op identity, `design.plasmid` +and `design.strain` stop being distinct ops, and the stage's representation is +what should change rather than its attribute list. `IrStage::Design` becomes an +`sbol3::Document` and the pliron design op degenerates to a reference carrying +an IRI, as argued above. Workflow and Protocol LAIR are untouched, because that +is where pliron's SSA and linearity analysis earn their place. + +Both versions push on the same wall: `source_lowering.rs` matching `"plasmid"` +and `"strain"` by name and reading fields by name. That is the unfinished half +of [0022](decisions/0022-fixed-grammar-open-vocabulary.md), "this removes +biology from the frontend only", and a Component carrying roles is what it needs +in order to become generic. + +### Diagnostics + +`ValidationIssue` carries a severity, a stable `rule: &'static str`, a subject +Resource, an optional property, and a closed `Hint` enum. Rendering it as a Lab +diagnostic needs a map from SBOL identity back to the source span that minted it. + +That map is a new capability rather than a migration, and it is worth being +clear about why. Nothing below `CheckedModule` carries a source position, by +design: "source text is deliberately absent: later compiler passes must not +reinterpret syntax". Spans went with the syntax. + +pliron does give every operation a `Location`, and `Operation::new` hard-defaults +it to `Location::Unknown`, which renders as `?`. `set_loc` is called zero times +in this repository, so all thirty or so `verify_err!(self.loc(ctx), ...)` sites +in the dialects produce a message with no position at all rather than a wrong +one. Today a lowering or backend error is reported by interpolating the artifact +name into a message string and cannot be underlined in an editor. + +This is a population problem, not an infrastructure problem, and pliron already +ships more of the model than is needed: + +```rust +pub enum Location { + SrcPos { src: Source, pos: SourcePosition }, + Fused { .. }, + Unknown, +} +``` + +`Fused` is the interesting one. A single LAIR op is often lowered from more than +one declaration, a `workflow.transform` combining a strain's declaration site +with the realizing workflow's action site, and `Fused` expresses exactly that +rather than forcing a choice between them. + +So the shape is: a `BTreeMap` side-table on `CheckedModule`, +threaded through `BuildArtifactIntent` in `source_lowering.rs`, which currently +carries only a name, and `set_loc` called at roughly fifteen construction sites +in `lair/program.rs`. No new location machinery. + +That is worth doing for its own sake, and SBOL validation is what makes it pay +for itself: it is the first pass that produces many precise, structured findings +about specific objects. + +`Hint::SuggestedTerm { iri, label }` renders as a term suggestion, which is the +diagnostics bar this repository holds itself to. + +Gate `lab build` on `Document::check_complete()`. A Lab program that compiles +emits valid SBOL by construction, or it does not compile. + +## What this does to the Python problem + +`marpaia/python` answers "how do I write Lab from Python". That is the wrong +question, and needing a source map to report errors is the symptom. + +The right question is "how does a Python lab work with Lab designs", and SBOL +answers it with no Lab-specific bridge at all: + +- Lab emits SBOL. Python reads it with `sbol`, the `sbol-py` bindings, which + wrap **the same Rust core the compiler used to write it**. No serialization + mismatch, no second model, no generated mirror to keep current, no codegen + staleness test. +- Analysis, plotting, LIMS integration, and notebook work happen against + `sbol.Document`, an API that already exists, is already documented, and + already covers SBOL 2, GenBank, and FASTA. +- The interchange is a validated standard document rather than a JSON dump of + `CheckedModule`, so the same file works in libSBOLj3, pySBOL3, SynBioHub, and + Benchling. + +What remains for a `lab` Python package is small and honest: run the compiler, +get diagnostics, get the emitted `sbol.Document`. A subprocess wrapper and a +re-export, not 1,900 lines of expression AST and frame introspection. + +The `sbol-py` README already states the principle this repository holds about +SDKs: "The API is idiomatic Python, not a clone of pySBOL3's mutable graph." +Mapping concepts onto the host language rather than mimicking a foreign syntax +is exactly what the Lab Python SDK failed to do, and it did not fail for lack of +effort. It failed because it had nothing to map onto. SBOL gives it something. + +If authoring Lab from Python is still wanted later, it should generate SBOL +rather than Lab source and let the compiler import it. Errors then point at +objects, and the source-map problem does not exist. + +`sbol-py` is currently `publish = false`, excluded from the workspace, and on +edition 2021 while everything else is 2024. Making this story real means +publishing it. That is a decision, not a blocker. + +## Sequence of work + +Ordered so that each step is useful on its own and none depends on a later one. + +1. Ontology grounding. The identity tail on `role`, the `Ty` to term resolver, + and the `sbol-ontology` checks. Small, self-contained, and everything else + depends on it. +2. Identity. Package namespace in `lab.toml`, `identity.rs` salvaged into the + frontend, `DefinitionId` made resolvable and actually read by its consumers, + IRI identities distinguished from catalog numbers. The dead `name@offset` + constructor is replaced by SBOL's child-naming convention. One schema bump. +3. The span side-table. `BTreeMap` on `CheckedModule`, + threaded through `BuildArtifactIntent`, and `set_loc` populated at the + fifteen or so LAIR construction sites, using `Location::Fused` where an op + derives from more than one declaration. Independently useful: it is what lets + any lowering or backend error be underlined at all. +4. SBOL emission from Design LAIR: Components, Sequences, SubComponents, + Constraints. Gate on `check_complete()`. The `SBO:0000241` fallback goes away. +5. Validation as a pass rather than a gate, run from `lab-project::compile`. + Whole-document only, once per module compile, not in the editor loop. The + per-module and per-program split waits on rule gates landing upstream. +6. Editor navigation on resolved identity. `definition`, `references`, and + `rename` consult `CheckedModule` instead of matching identifier text. This + needs nothing from SBOL beyond step 2 and fixes a rename that today rewrites + every matching word in every open file. +7. Sequence computation and cross-checking. GenBank and FASTA import. `sites()` + gets an implementation. +8. Interactions and Interfaces from circuit types, reading `CheckedSection`. +9. PROV-O from the lineage analysis: Implementation, Activity, Experiment, + ExperimentalData. +10. Import. SBOL to typed Lab declarations, `lab import `. A dependency may + be an SBOL document rather than Lab source. +11. The run ledger and coordination plan emit PROV-O against the same + identities, closing design to run to material. +12. Combinatorial derivations. +13. `IrStage::Design` becomes an `sbol3::Document`. This is last because it is + the only step that is purely a simplification: by then everything it would + carry is already being produced. +14. Retire the Python source generator. Publish `sbol-py`. Ship a thin `lab` + package. + +Steps 2, 3, and 6 are worth doing whether or not the SBOL work continues. That +is a useful property for the first third of a plan this size. + +Four items are upstream work in sbol-rs rather than Lab work, and they gate +parts of the above. Rule gates in the SBOL 3 catalog, so validation can be tiered. +OM unit constants, so quantities emit as `Measure` values. A fuller SO import +with real parent chains plus open namespace registration, if terms are ever to +classify types. And origin-aware coordinates, if circular features are to be +located rather than only counted. Since sbol-rs is maintained in-house these are +schedulable rather than blocking, but they are a second track and should be +planned as one. + +LabOP stays as a secondary emission derived from the same SBOL document, with +its omissions report intact. It is a projection of the output, not a peer of it. + +## Open questions and risks + +**`sbol3` has no serde.** `CheckedModule` is serde-serialized under +`lab.portable-module.v4`, so SBOL objects cannot ride inside the portable module +IR. Keeping IRIs as strings in `CheckedModule` and rebuilding SBOL objects at +emission is probably right, since it keeps the portable IR self-describing, but +it needs deciding rather than discovering. + +**No OM unit constants in sbol-rs.** Lab has `Quantity
      `, `Quantity`, and +`Quantity`, and emitting them as OM `Measure` values needs unit IRIs that +`sbol3::constants` does not carry. `marpaia/labop`'s `vocabulary.rs` already has +`Unit::{Microlitre, Celsius, Minute}` to salvage; contributing the constants +upstream is the better end state. + +**Namespaces are a policy call.** Whether a package must declare one, and what a +project without a SynBioHub account uses. A `https://lab-lang.org/local/` +default works but publishes IRIs that do not resolve, which is its own kind of +lie. + +**Round-trip fidelity is a two-way claim and needs a test.** Lab-specific +properties such as `reaction_volume` and `assembly_cycles` ride as extension +triples under a `lab:` namespace and survive third-party round trips. But +sbol-rs drops subjects carrying only extension predicates and no SBOL type, so +Lab's own object types must carry `sbol:Identified` through +`IdentifiedExtension`. The labop branch's `sbol3::Document::read` plus +`validate()` harness is the right place to assert it. + +**Reverse traversal in sbol-rs is a linear scan**, documented as such. Fine at +the scale of the golden-gate example, worth an index for a project with +thousands of parts. + +**Quantity units are exact; ontology terms would like to be hierarchical.** +[0025](decisions/0025-quantity-types.md) pins a unit exactly and refuses +conversion, and grounding roles in terms invites the opposite instinct, that a +promoter should satisfy a bound written for a regulatory region. The bundled +snapshot cannot answer that question at all, so the conservative reading holds +for now: terms are validated, not used to classify. Revisit only alongside the +upstream ontology work, and decide deliberately rather than inheriting whatever +the snapshot happens to encode. + +**If the type system ever reads the ontology, the ontology becomes a pinned +dependency.** A snapshot update would otherwise change what compiles. `lab.lock` +is the place, using the `TSV_FORMAT_VERSION` and per-source `raw_sha256` that +`sbol-ontology` already carries. + +**Two-tier validation needs an upstream change first.** Gating the completeness +family per module depends on rules in `crates/sbol3/rules.toml` carrying gates, +and none of the 149 do. Sequence the upstream catalog change before the Lab-side +pass, or the pass runs every rule at every boundary. + +**Dependency weight at the wasm boundary.** `lab-ide-wasm` builds on +`lab-language`. Ontology facts are an embedded TSV and are fine there; `oxrdf` +and the RDF I/O stack are not obviously fine. This is why the validation pass +runs from `lab-project` rather than from `compile_parsed_module`, and it needs +measuring rather than assuming. + +**Identity migration is broad, and it breaks two wire formats.** +`PORTABLE_MODULE_SCHEMA_VERSION` moved to `lab.portable-module.v4` when grounding +landed; `DependencyBuildManifest` serializes artifact names into an on-disk manifest +with its own `schema_version`. Both are deliberate, versioned boundaries, so the +break is manageable, but it should be one break rather than several. Land the +identity type and the minting rules first, then move consumers, rather than +letting IRIs leak outward one pass at a time. + +The checker's tables are the bulk of the mechanical work: fifteen +`HashMap` and `BTreeSet` fields on `SemanticContext`, plus +one flat module-wide name table in `declarations.rs`. There are no scopes to +rework, because there is no scope stack; local environments are cloned +`HashMap` values passed by argument. diff --git a/docs/language/specimens/dependency-build.lab b/docs/language/specimens/dependency-build.lab index 298545b..a96feac 100644 --- a/docs/language/specimens/dependency-build.lab +++ b/docs/language/specimens/dependency-build.lab @@ -10,9 +10,9 @@ use std.bio.build use std.lab.plasmid buy: - part J23101 + promoter J23101 part B0034 - part GFP + cds GFP part B0015 backbone part_receiver backbone region_receiver diff --git a/docs/language/specimens/inventory-plasmid.lab b/docs/language/specimens/inventory-plasmid.lab index 54ffcbf..7a890ac 100644 --- a/docs/language/specimens/inventory-plasmid.lab +++ b/docs/language/specimens/inventory-plasmid.lab @@ -10,9 +10,9 @@ use std.bio.build use std.lab.plasmid buy: - part J23101 + promoter J23101 part B0034 - part GFP + cds GFP part B0015 backbone pSB1C3 restriction_enzyme BsaI diff --git a/docs/language/specimens/plasmid-build.lab b/docs/language/specimens/plasmid-build.lab index 4c6247a..d74af60 100644 --- a/docs/language/specimens/plasmid-build.lab +++ b/docs/language/specimens/plasmid-build.lab @@ -8,7 +8,7 @@ buy: antibiotic kanamycin restriction_enzyme BsaI backbone p15A_kan - part GFP + cds GFP build plasmid p_reporter: sequence = dna("ACGTACGT") diff --git a/docs/language/support.md b/docs/language/support.md index e3b1120..df4ca9f 100644 --- a/docs/language/support.md +++ b/docs/language/support.md @@ -36,6 +36,8 @@ Support is tracked by compiler phase. `Lower` means verified portable module IR | Registry dependency acquisition | n/a | rejected | no | no | no | | `role` declarations and `is` membership | yes | yes | bounds satisfied by role membership | `CheckedDeclaration::Role`, roles on type exports | n/a | | Roles crossing a module boundary | n/a | `ExportKind::Role` | membership restored from the interface | yes | n/a | +| Ontology grounding | `role X = "SO:0000167"`, `artifact P is X` | role terms and kind membership | term shape checked where written | `Grounding` resolves a type to its terms | not yet read by a target | +| Designs read from SBOL | an SBOL document in place of `.lab` designs | catalogued declarations built and then checked | the same rules a written design meets | `lab-sbol` reads components, sequences, and ordered parts | file discovery pending | | Circuit declarations and applications | yes | yes | yes | yes | no | | Callable circuit signatures with `-> T` | yes | yes | yes | yes | no | | Inline type parameters (`Promoter`) | yes | yes | harvested in signature order, bounds checked at the call | `parameters` and `bounds` in the portable module and its interface | n/a | @@ -73,7 +75,7 @@ The separate OT-2 specialization accepts plasmid properties (`backbone`, ordered Deck layout, labware, instruments, and per-stage capacity come from a target profile rather than from constants, and allocation spills across every plate a profile declares. The target validates reaction balance against each design's own stated volume, replicate and dilution bounds, plate capacity, source-rack capacity, and tip capacity. A batch emits a robot protocol only for the stages its artifacts reach, and artifacts sharing a planning wave share one run. -It does not yet resolve SBOL, inventory lots, overhang compatibility, sequence redesign, concentration normalization, inter-wave DNA preparation, or runtime acceptance evidence. Generated instructions and scripts require laboratory review and qualification before physical execution. The complete specialization boundary is documented separately in [`../integrations/opentrons-build.md`](../integrations/opentrons-build.md). +It does not yet read the ontology terms a kind is grounded in, nor resolve SBOL, inventory lots, overhang compatibility, sequence redesign, concentration normalization, inter-wave DNA preparation, or runtime acceptance evidence. Generated instructions and scripts require laboratory review and qualification before physical execution. The complete specialization boundary is documented separately in [`../integrations/opentrons-build.md`](../integrations/opentrons-build.md). ## Editor support diff --git a/docs/language/syntax.md b/docs/language/syntax.md index 2b9099d..3979e06 100644 --- a/docs/language/syntax.md +++ b/docs/language/syntax.md @@ -96,6 +96,19 @@ record Arabinose is Inducer record Tetracycline is Inducer ``` +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: + +```lab +role NucleicAcid = "SBO:0000251" +role EngineeredRegion = "SO:0000804" +``` + +A term is an absolute IRI or a compact identifier, and the two spellings of one +term mean the same thing. A type that plays a grounded role stands for its term, +which is how a design says what it is in a vocabulary other tools read. A role +that names no term classifies types and says nothing about any ontology. + `Signal` and `Protein` are roles the prelude already declares, so a module declares its own rather than redeclaring those. A role takes no block. Its members are declared by the types that play it, so a package can classify its own types against a role it imported, and a role stays @@ -281,7 +294,7 @@ values, so a reader can tell at a glance which they are looking at. ```lab /** A DNA design a laboratory can build. */ -artifact Plasmid: +artifact Plasmid is NucleicAcid, EngineeredRegion: sequence?: DNA backbone?: Backbone cargo?: Circuit @@ -289,6 +302,10 @@ artifact Plasmid: declares sequence or (backbone and cargo) ``` +A kind may play roles, written with the same `is` clause a record uses. The +roles classify the type the kind produces, so a kind grounded in an ontology +states what its instances are. + A kind names the type its instances have, which is what a workflow writes in `Material` and what `require` and `accept` read fields from. The word those instances are written with is that type in snake_case — `Plasmid` gives diff --git a/examples/golden-gate-extended/lab.toml b/examples/golden-gate-extended/lab.toml index 4b077e0..6f4b3f0 100644 --- a/examples/golden-gate-extended/lab.toml +++ b/examples/golden-gate-extended/lab.toml @@ -9,16 +9,19 @@ target = "opentrons-ot2" [inventory] materials = [ - "B0015", - "B0034", "BL21", # BsaI states a supplier identity, so that is what an order names. "NEB-R0535", "DH5alpha", - "GFP", - "J23101", - "J23106", - "RFP", + # The DNA parts are declared in SBOL, where a part's identity is the registry + # record it resolves to. That record is what an order names, so it is what is + # listed here rather than the bare symbol a Lab declaration would have used. + "https://synbiohub.org/public/igem/B0015", + "https://synbiohub.org/public/igem/B0034", + "https://synbiohub.org/public/igem/GFP", + "https://synbiohub.org/public/igem/J23101", + "https://synbiohub.org/public/igem/J23106", + "https://synbiohub.org/public/igem/RFP", "T4_DNA_ligase", "T4_DNA_ligase_buffer", "chloramphenicol", diff --git a/examples/golden-gate-extended/src/designs/inventory.lab b/examples/golden-gate-extended/src/designs/inventory.lab index 368fc8d..1b68881 100644 --- a/examples/golden-gate-extended/src/designs/inventory.lab +++ b/examples/golden-gate-extended/src/designs/inventory.lab @@ -1,5 +1,10 @@ /*! - * What this laboratory orders rather than makes. + * What this laboratory orders rather than makes, minus the DNA parts. + * + * The promoters, ribosome binding site, terminator, and reporters live in + * `parts.ttl` beside this file, written in SBOL. A design is a design whichever + * language states it, so the plasmids here name those parts exactly as if they + * had been written below. * * A bought item carries its own datasheet. The temperature a digest runs at * belongs to the enzyme, and the way competent cells are shocked belongs to the @@ -9,15 +14,6 @@ use std.bio.designs buy: - // Constitutive promoters, the shared ribosome binding site and terminator, - // and the fluorescent reporters. - part J23101 - part J23106 - part B0034 - part B0015 - part GFP - part RFP - backbone pSB1C3 /** BsaI cuts at 37 C, so every plasmid it opens digests the same way. */ diff --git a/examples/golden-gate-extended/src/designs/parts.ttl b/examples/golden-gate-extended/src/designs/parts.ttl new file mode 100644 index 0000000..81e0845 --- /dev/null +++ b/examples/golden-gate-extended/src/designs/parts.ttl @@ -0,0 +1,105 @@ +# The parts this panel is assembled from, written in SBOL rather than in Lab. +# +# Nothing here is Lab-specific. This is an ordinary SBOL 3 document of the kind +# a registry exports, and the compiler reads it as a module: each Component +# becomes a catalogued design, typed by the terms it states about itself and +# keeping its registry IRI as the identity an order is placed against. +# +# Sequences are synthetic compiler fixtures, not qualified biological designs. + +@prefix sbol: . +@prefix dcterms: . + + + a sbol:Component ; + sbol:displayId "J23101" ; + sbol:hasNamespace ; + dcterms:description "Anderson constitutive promoter, strong" ; + sbol:type ; + sbol:role ; + sbol:hasSequence . + + + a sbol:Sequence ; + sbol:displayId "J23101_sequence" ; + sbol:hasNamespace ; + sbol:encoding ; + sbol:elements "TTGACAGCTAGCTCAGTCCTAGGTATTATGCTAGC" . + + + a sbol:Component ; + sbol:displayId "J23106" ; + sbol:hasNamespace ; + dcterms:description "Anderson constitutive promoter, medium" ; + sbol:type ; + sbol:role ; + sbol:hasSequence . + + + a sbol:Sequence ; + sbol:displayId "J23106_sequence" ; + sbol:hasNamespace ; + sbol:encoding ; + sbol:elements "TTTACGGCTAGCTCAGTCCTAGGTATAGTGCTAGC" . + + + a sbol:Component ; + sbol:displayId "B0034" ; + sbol:hasNamespace ; + dcterms:description "Ribosome binding site" ; + sbol:type ; + sbol:hasSequence . + + + a sbol:Sequence ; + sbol:displayId "B0034_sequence" ; + sbol:hasNamespace ; + sbol:encoding ; + sbol:elements "AAAGAGGAGAAA" . + + + a sbol:Component ; + sbol:displayId "B0015" ; + sbol:hasNamespace ; + dcterms:description "Double terminator" ; + sbol:type ; + sbol:hasSequence . + + + a sbol:Sequence ; + sbol:displayId "B0015_sequence" ; + sbol:hasNamespace ; + sbol:encoding ; + sbol:elements "CCAGGCATCAAATAAAACGAAAGGCTCAGTCG" . + + + a sbol:Component ; + sbol:displayId "GFP" ; + sbol:hasNamespace ; + dcterms:description "Green fluorescent protein coding sequence" ; + sbol:type ; + sbol:role ; + sbol:hasSequence . + + + a sbol:Sequence ; + sbol:displayId "GFP_sequence" ; + sbol:hasNamespace ; + sbol:encoding ; + sbol:elements "ATGACCATGATTACGCCAAGCTTGGTACCGAGCTC" . + + + a sbol:Component ; + sbol:displayId "RFP" ; + sbol:hasNamespace ; + dcterms:description "Red fluorescent protein coding sequence" ; + sbol:type ; + sbol:role ; + sbol:hasSequence . + + + a sbol:Sequence ; + sbol:displayId "RFP_sequence" ; + sbol:hasNamespace ; + sbol:encoding ; + sbol:elements "ATGGCCTCCTCCGAGGACGTCATCAAGGAGTTCATG" . diff --git a/examples/golden-gate-extended/src/designs/plasmids.lab b/examples/golden-gate-extended/src/designs/plasmids.lab index 464e70c..7931174 100644 --- a/examples/golden-gate-extended/src/designs/plasmids.lab +++ b/examples/golden-gate-extended/src/designs/plasmids.lab @@ -5,12 +5,19 @@ * repository already made. Being built is a fact about a particular plasmid * rather than about plasmids, so both are the same kind of thing and only their * provenance differs. + * + * Sequences are synthetic compiler fixtures, not qualified biological designs. + * Each assembled sequence is exactly the concatenation of the parts listed + * under `components`, in that order, so the design stays true once the compiler + * computes an assembled sequence rather than taking one on trust. */ use std.bio.designs use std.bio.golden_gate use golden_gate_extended.designs.inventory +// The DNA parts, written in SBOL rather than in Lab. +use golden_gate_extended.designs.parts /** * The GFP reporter under a strong constitutive promoter. @@ -20,7 +27,7 @@ use golden_gate_extended.designs.inventory * has to clear a bar on enough independent clones to mean something. */ build plasmid composite_plasmid_1: - sequence = dna("GCTAGCGGATCCATGACCATGATTACGCCAAGCTTGAATTC") + sequence = dna("TTGACAGCTAGCTCAGTCCTAGGTATTATGCTAGCAAAGAGGAGAAAATGACCATGATTACGCCAAGCTTGGTACCGAGCTCCCAGGCATCAAATAAAACGAAAGGCTCAGTCG") backbone = pSB1C3 components = [J23101, B0034, GFP, B0015] restriction_enzyme = BsaI @@ -50,7 +57,7 @@ build plasmid composite_plasmid_1: * is a property of the design rather than of the protocol that built it. */ build plasmid composite_plasmid_2: - sequence = dna("GCTAGCGGATCCATGGCCTCCTCCGAGGACGTCATCAAGGAATTC") + sequence = dna("TTTACGGCTAGCTCAGTCCTAGGTATAGTGCTAGCAAAGAGGAGAAAATGGCCTCCTCCGAGGACGTCATCAAGGAGTTCATGCCAGGCATCAAATAAAACGAAAGGCTCAGTCG") backbone = pSB1C3 components = [J23106, B0034, RFP, B0015] restriction_enzyme = BsaI @@ -80,4 +87,4 @@ build plasmid composite_plasmid_2: */ buy plasmid reference_gfp: identity = "Addgene-#134516" - sequence = dna("GCTAGCGGATCCATGACCATGATTACGCCAAGCTTGAATTC") + sequence = dna("TTGACAGCTAGCTCAGTCCTAGGTATTATGCTAGCAAAGAGGAGAAAATGACCATGATTACGCCAAGCTTGGTACCGAGCTCCCAGGCATCAAATAAAACGAAAGGCTCAGTCG") diff --git a/examples/golden-gate/src/designs/inventory.lab b/examples/golden-gate/src/designs/inventory.lab index 775b52a..543b853 100644 --- a/examples/golden-gate/src/designs/inventory.lab +++ b/examples/golden-gate/src/designs/inventory.lab @@ -9,14 +9,26 @@ use std.bio.designs buy: - // Constitutive promoters of differing strength, the shared ribosome binding - // site and terminator, and the fluorescent reporters. - part J23101 - part J23106 - part B0034 - part B0015 - part GFP - part RFP + // Constitutive promoters of differing strength. Each is a promoter rather + // than a bare part, so the compiler knows what it is without being told + // again wherever it is used. + promoter J23101: + sequence = dna("TTGACAGCTAGCTCAGTCCTAGGTATTATGCTAGC") + promoter J23106: + sequence = dna("TTTACGGCTAGCTCAGTCCTAGGTATAGTGCTAGC") + + // The shared ribosome binding site and terminator. Neither has a narrower + // kind here, so both are parts; a package that declares one may say more. + part B0034: + sequence = dna("AAAGAGGAGAAA") + part B0015: + sequence = dna("CCAGGCATCAAATAAAACGAAAGGCTCAGTCG") + + // The fluorescent reporters, each a coding sequence. + cds GFP: + sequence = dna("ATGACCATGATTACGCCAAGCTTGGTACCGAGCTC") + cds RFP: + sequence = dna("ATGGCCTCCTCCGAGGACGTCATCAAGGAGTTCATG") // Assembly backbone and the type IIS enzyme that opens it. backbone pSB1C3 diff --git a/examples/golden-gate/src/designs/plasmids.lab b/examples/golden-gate/src/designs/plasmids.lab index 12e5a84..1196eb5 100644 --- a/examples/golden-gate/src/designs/plasmids.lab +++ b/examples/golden-gate/src/designs/plasmids.lab @@ -3,6 +3,10 @@ * reporter through a shared RBS and terminator. * * Sequences are synthetic compiler fixtures, not qualified biological designs. + * Each composite sequence is exactly the concatenation of the parts listed + * under `components`, in that order, so the design stays true once the compiler + * computes an assembled sequence rather than taking one on trust. + * * The reaction chemistry in each design is scientific intent and travels with * the artifact; where the reaction physically happens is a target profile's * concern. @@ -20,7 +24,7 @@ use golden_gate.designs.inventory * Gate with BsaI. Accepted only if the built sequence matches the design. */ build plasmid composite_plasmid_1: - sequence = dna("GCTAGCGGATCCATGACCATGATTACGCCAAGCTTGAATTC") + sequence = dna("TTGACAGCTAGCTCAGTCCTAGGTATTATGCTAGCAAAGAGGAGAAAATGACCATGATTACGCCAAGCTTGGTACCGAGCTCCCAGGCATCAAATAAAACGAAAGGCTCAGTCG") backbone = pSB1C3 components = [J23101, B0034, GFP, B0015] restriction_enzyme = BsaI @@ -46,7 +50,7 @@ build plasmid composite_plasmid_1: * reporters. */ build plasmid composite_plasmid_2: - sequence = dna("GCTAGCGGATCCATGGCCTCCTCCGAGGACGTCATCAAGGAATTC") + sequence = dna("TTTACGGCTAGCTCAGTCCTAGGTATAGTGCTAGCAAAGAGGAGAAAATGGCCTCCTCCGAGGACGTCATCAAGGAGTTCATGCCAGGCATCAAATAAAACGAAAGGCTCAGTCG") backbone = pSB1C3 components = [J23106, B0034, RFP, B0015] restriction_enzyme = BsaI