LB media and competent cells - #38
Merged
Merged
Conversation
A material's state was encoded as a separate type, which is why `Culture`, `Clone`, and `Plate` are fieldless: they are not kinds of thing, so there is no design underneath them to ask what is growing or what it grows in. LAIR already modelled states correctly and verified them, but the source language could not name one, so the state was assumed at the lowering site: every `provision` minted competent cells, and fetching an antibiotic produced a value the IR believed was a tube of cells. A `facet` declares the states a kind's materials may be in, the fields each state carries, and the transitions between them. Several facets classify one kind independently, so a culture that is both diluted and grown under selection is two facets rather than one state naming both. An instance states its state with the facet's name in snake_case, and a type argument narrows to one with the `is` that already says a type plays a role. Narrowing runs one way: `Material<Chassis is competent>` may be used where `Material<Chassis>` is expected, never the reverse. That is what makes transforming into cells nobody made competent a diagnostic at the operand. The state rides on the type argument rather than wrapping the material, because ownership and linearity analysis find a material by its outermost name and both end in wildcards. Wrapping would have dropped exactly the state-carrying materials out of affine checking. Workflow LAIR's material type carries an absolute IRI instead of one of seven variants, and the table translating variants to IRIs is gone. The enumeration was already too narrow: `method::standard` mints three states it never had. A Method port may now say its state is the one its Intent asked for, which is what lets one provisioning signature serve every kind of thing a shelf holds. Unit safety now holds wherever two measurements meet, not only across an assignment. `20 uL + 5 mL` and `volume > 5 mL` were accepted and are now refused; two shipped files compared base pairs to kilobases. A measurement scales by a plain number, so `20 uL * 3` states a batch, and a quantity can be divided at all, which a unit denominator used to swallow. Mass and mass concentration exist as canonical Procedure quantities. Nothing constructs one yet; a medium recipe needs them and cannot lower without them.
The regex that added `competent` to each chassis declaration assumed a single-line import and produced `from lab.bio.designs import (, competent` in the Golden Gate example, which is not valid Python, and left `CDS` sorted after `Antibiotic` where ruff wants all-caps names first. CI lints and runs only `crates/lab-python`, so the broken example parsed nowhere and the import order failed only once it reached GitHub.
Five things were listed as unfinished when the state work landed. Each is closed here, and two of them turned out to be defects rather than absences. Measurements compose. `10 g/L * 500 mL` is `5 g`, computed where it is written, so a recipe states concentrations once and a build reads grams. A unit carries a dimension and a power of ten to its canonical unit, which keeps every conversion exact; durations convert by sixties in a table of their own. `in` converts where a conversion is written, so `12 kb in bp` is what a person means and what the field holds. A field may ask for a dimension instead of a unit. Mass in a volume and amount in a volume stay different things, because going between them needs a molar mass and that is a fact about the substance. Scientific notation lexes, because a transformation efficiency is 1e9 cfu/ug and writing that out is a row of zeros to miscount. A state's declared fields are required wherever the state is stated. Cells are not competent in the abstract; they are competent to a number, and that number is what a batch is accepted on. `Culture`, `Clone`, and `Plate` stop being types. A culture is a strain that has recovered, a colony is a strain that was isolated, and a plate is a medium that was poured and spread on. Each now names what it is made of, which is what none of them could do. Plating states the medium rather than an antibiotic beside it, so what a plate selects for is read from what it is. Two lineage defects fell out of that. A plate is a culture on agar, and counting the agar made one plate look like two independent organisms, so a contract may now name operands that carry no lineage. And fetching one shelf item twice minted two origins, which would have let a program claim replicates it does not have; naming the same thing twice fetches one thing. Mass and mass concentration are reachable from a method parameter rather than sitting unwired.
`realize` was typed to `Plasmid` at the contract, hardcoded `PlasmidProduct`
in the IR op, and refused every other kind at lowering, so the one verb wired
end to end could make exactly one kind of thing. Realizing is making what a
declaration describes, whatever that is: the declaration carries the recipe,
and how making it runs is a Method's business.
The contract takes any declared design and yields its material. A kind with no
lowering contract of its own lowers as a made artifact: a design op stating
its name and kind, and a realization whose product state is named for what is
being realized, so a medium arrives as a MediumProduct the way a plasmid
arrives as a PlasmidProduct. All three realization methods say their product
state is the one the Intent asked for, which keeps the registry's one
signature per Intent; the Golden Gate candidates drop out of any realization
that states no assembly recipe, so making a medium refines to manual
realization alone. A Golden Gate recipe still implies a plasmid, and the
verifier says so.
With that, the protocol this work set out to express compiles through to the
planning problem, in both languages:
build medium LB_broth:
components = [
Ingredient { substance: "tryptone", concentration: 10 g/L },
Ingredient { substance: "yeast extract", concentration: 5 g/L },
Ingredient { substance: "sodium chloride", concentration: 10 g/L },
]
workflow make_LB() -> Material<Medium>:
product <- realize LB_broth
return product
`lower_type` was missing `Decimal` from its builtin dispatch, which surfaced
as "expects Decimal, found Decimal" the first time a schema field was typed
with it.
The contract a durable action is checked against was built entirely from `&'static str`: every phrase word, operand name, and unit was a literal, which suited definitions written into the compiler and nothing else. A verb a package declares in source produces owned strings at runtime, so the representation has to own them too. The phrase parts, contract type references, result names, operation identity, and the inert-operand list are `String` now, with ergonomic constructors so the standard-library definitions read as they did. Nothing changes about what is checked; this is the representation the `action` declaration form needs to produce.
The six verbs that lower were Rust ops, and the dozen that only type-check were entries in a compiled table. Neither is something a laboratory can add, so the vocabulary of what a protocol does was closed in a way the vocabulary of what it works on is not. An `action` declares a verb the way a workflow writes it: the phrase with its operands in `<>`, the results after `->`, and a body that types each operand and result, gives an operand its ownership mode, and names the capability a facility must offer. It checks a workflow exactly as a bundled verb does, because it produces the same contract, and it crosses a module boundary with its phrase, types, and capability intact. This is the frontend half. A declared verb type-checks and is refused where it is misused: a wrong word, a missing operand, a measurement in the wrong unit, or no capability. What it does not yet do is lower, which is the same state the dozen contracted-but-unlowered bundled verbs are already in. The generic operation that lowers it, and the manual method derived from its capability, come next.
The `action` declaration form gave a package the vocabulary to write a durable verb and have a workflow check against it, but every verb still needed a Rust lowering and a hand-written method to reach a plan. A declared verb had nowhere to go once it left the checker. A workflow.perform Intent carries everything a declared verb needs: the operation it refines, the capability that runs it, the material operands it consumes, the scalar parameters it carries as the text they were written as, and the state each result arrives in. Lowering routes any verb that is not one of the six with a bespoke lowering into a perform, reading its operands, parameters, and result states from the checked call. Refinement recognizes a perform and derives one manual bench method for it on the spot: the operands become inputs in the states the IR gives them, the parameters ride through, the capability becomes the requirement, and each result is requested from the Intent so it arrives in the state the verb declared. No registry entry, no per-verb Rust. With that, a package can declare a verb the compiler has never seen and a workflow that performs it lowers to a perform Intent and refines to a derived-<operation> method, through to the planning problem.
Growing cells up, chilling them, spinning them into a pellet, and washing them into cold buffer are everyday bench steps the compiler had no words for. They are not assembly or transformation, so none of them fit the six bundled verbs, and until a package could declare a verb there was nowhere to put them. std.lab.competence declares them as actions: grow, chill, centrifuge, and resuspend, over a Growth facet that carries a chassis from dormant through growing to pelleted, with the existing Competence facet's competent as the state the wash leaves it in. grow reaches a target optical density read at either wavelength, written `to 0.40 OD600` or `to 0.40 OD700`; the two are distinct units that do not convert, so the operand is a union of the two measurements and the protocol writes whichever its plate reader reports. A buffer and a medium both play a new Solution role, so resuspend pours either. Three things the vocabulary needed the frontend to learn. A measured operand now carries a decimal magnitude, because an optical density is 0.40 and not 0. A union of measurements is one operand read in any of its units, which is what the OD600-or-OD700 endpoint is. And a declared verb carries the capability it needs on the resolved call, so the derived method requires it whether the verb is declared here or imported. The bundled plasmid-prep grow, which cultures a clone for a miniprep, is now `culture`, so the two no longer collide on a name. The whole protocol checks, lowers to perform Intents, and refines to derived manual methods, in Lab as a compiler acceptance test and in Python as an SDK one, through to the planning problem.
A step an instrument runs arrives with its own operator document, rendered by the adapter that lowered it. A manual-control step has no adapter, so a plan whose steps are all manual produced a reviewed plan.execution.json and nothing a person could print. For a human-operated facility that is every plan, and the bench was left reading JSON. lab plan and lab build now typeset an operator run sheet for the plan's manual steps: one document for the whole protocol, each step stating the operation it performs, the asset it uses, and the parameters it runs at, in the order the plan performs them. The values read the way the workflow wrote them, so a competent-cell prep prints "37 C", "0.4 OD600", and "4000 rcf". lab-adapters owns the rendering beside the instrument documents, lab-facility projects the allocated manual steps into display text, and the CLI typesets the sheet into the plan's documents directory beside its Typst source and style sheet, listed under the same Documents output as the adapter-emitted manuals.
The translator collected the modules a body's vocabulary comes from in a set, so the emitted `use` lines came out in string-hash order: the same program produced different bytes from one interpreter to the next, and a checked-in emitted module went stale by rerunning its own generator. Module.imports promises the order the modules are first needed, and now the collection keeps it, so emission is byte-stable.
A protocol is one procedure however many designs it runs for, but lowering keyed each build flow by the name of the design its workflow realized, so a workflow realizing its own parameter left every declared artifact without a flow and the same wash had to be written out once per strain. A workflow that realizes one of its parameters is now a template. Its flow is design-agnostic already, since the realize op takes the design from the artifact being built, so the call site is what names the design: each call instantiates the flow for the artifact its argument references, and an argument that is itself the caller's parameter names no design yet. With that, `prepare_competent_cells(chassis: Chassis)` is written once and builds DH5alpha, DH5beta, and Top10 from three calls.
The emitted use lines came only from a workflow's body, so a workflow that composes others and returns their materials emitted no import for the types its own signature names: a catalog build returning Material<Chassis is competent> compiled to a module that could not see the Competence facet. The signature's annotations now contribute their modules too, after the ones the body needed, so composing workflows is enough to make a module.
A workspace declares more than any one run builds: a protocol catalog holds every medium and every prep, and a bench run makes exactly one of them. But a package had one build.entry, and lowering required every declared artifact to be realized by some workflow in scope, so the only way to plan anything was a single entry that built everything. Every source under src/programs/ is now a runnable program, named by its file, and `lab plan --program <name>` roots the build at that program's main: the artifacts are the ones main reaches through workflow calls, a declaration nothing reaches is a library entry rather than an error, and the plan and its run sheet land under .lab/plan/<name> so programs do not overwrite each other. Without --program the manifest entry plans exactly as before, and a package with programs but no entry says which names to pick from. `lab build --program` roots the facility outputs the same way.
A workspace whose Lab another frontend emits had no CLI-native way to stay fresh: the Python protocols exported their sources with a side script, and forgetting it compiled yesterday's Lab. The manifest now declares the generator the way a build script would: [build] generate = "uv --project .. run python ../scripts/export_lab.py" lab check, lab plan, and lab build run it from the package root before discovering sources, so the entry can even be a file the generator has not written yet, and a frontend-emitted workspace plans with exactly the commands a native one does. The generator's output surfaces only when it fails, alongside the command that failed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
I do two things in the lab constantly: make LB media, and make competent cells. Lab couldn't express either one. As of this branch, making media works end to end, and the type system that competent cell prep needs is in place.
Making LB broth compiles through to the planning problem
That lowers to a
design.made_artifactplus aworkflow.realizewhose product is aMediumProduct, refines to the manual realization method (the Golden Gate candidates drop out because the Intent states no assembly recipe), and extracts into the planning problem.realizehad been typed toPlasmidat the contract, hardcodedPlasmidProductin the IR, and refused everything else at lowering; it now realizes whatever a declaration describes, and a Golden Gate recipe still implies a plasmid, which the verifier enforces. Same from Python:Medium.build(...)andlab.realize(LB_broth)render exactly the Lab above.The recipe scales, because measurements compose:
A unit carries a dimension and a power of ten to its canonical unit, so conversion is exact; durations convert by sixties in their own table;
inconverts where a conversion is written; a field may ask forQuantity<any Concentration>instead of pinning a unit. Mass in a volume and amount in a volume stay distinct, since going between them takes a molar mass, which is a fact about the substance and not the recipe.A material carries the state it's in, and the compiler checks it
Transform into cells declared
naiveand you get:Culture,Clone, andPlateare gone as types. A culture is a strain that has recovered, a colony is a strain that was isolated, and a plate is a medium that was poured and spread on. Plating names the medium rather than an antibiotic beside it, so what a plate selects for is read from what it's made of. Retiring them caught two real mistakes in existing fixtures: plating a culture that hadn't recovered, and plating on nothing anyone could name. In Python a state is a generated class, soMaterial[inoculated[Medium]]typechecks under mypy and a barecompetentstill renders as the word.Things a reviewer should weigh
The state sits on the type argument, not around the material. Wrapping (
Ty::InState(Material<..>, ..)) looks natural and silently disables affine tracking, because ownership analysis finds a material by its outermost name and falls through a wildcard. There's a test asserting a narrowed material still trips affine reuse.PortType::MaterialAsRequested. Method ports name their state literally and the registry requires every candidate refining one Intent to share a signature, but provisioning and realization are polymorphic in what they yield. Such a port takes its state from the Intent result it's exported as. It's refused on inputs and on unexported task outputs, where there'd be nothing to read the state from. This is the one genuinely new concept in the method layer and I'd like a second opinion on it.Two lineage rules. A contract can mark operands
inertso a plate doesn't count its agar as a second organism, and fetching one shelf item twice yields one entity rather than letting a program mint replicates by writingprovisiontwice.Source breaks
A chassis states
competenceandefficiency; plating takes a medium instead of an antibiotic;Material<Plate>,Material<Culture>, andMaterial<Clone>becomeMaterial<Medium is inoculated>,Material<Strain is recovered>, andMaterial<Strain is isolated>.What's still missing
Competent cell prep is not writable yet:
grow,wash, and friends don't lower, and 0054'sdraw(material quantity and division) is accepted but unimplemented. Making media stops at the planning problem; no facility in the examples advertises manualArtifactRealization, so there's no reviewed plan exercising it yet.Testing
567 Rust tests, 102 Python tests, fmt and clippy clean under
-D warnings. Every stage above is asserted ina_medium_realizes_without_being_a_plasmid.If you touch the language:
cargo test --workspacedoesn't run the Python suite, andpython/lab/_native.abi3.sogoes stale silently. Runscripts/check-python-sdk.shafteruv run maturin develop --uv.