Skip to content

LB media and competent cells - #38

Merged
marpaia merged 14 commits into
masterfrom
marpaia/media
Sep 5, 2026
Merged

LB media and competent cells#38
marpaia merged 14 commits into
masterfrom
marpaia/media

Conversation

@marpaia

@marpaia marpaia commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

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

build medium LB_broth:
  sbol_identity = "https://example.org/media/LB_broth"
  ph = 7.0
  components = [
    Ingredient { substance: "tryptone", concentration: 10 g/L },
    Ingredient { substance: "yeast extract", concentration: 5 g/L },
    Ingredient { substance: "sodium chloride", concentration: 10 g/L },
  ]

workflow make_LB() -> Material<Medium>:
  product <- realize LB_broth
  return product

That lowers to a design.made_artifact plus a workflow.realize whose product is a MediumProduct, 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. realize had been typed to Plasmid at the contract, hardcoded PlasmidProduct in 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(...) and lab.realize(LB_broth) render exactly the Lab above.

The recipe scales, because measurements compose:

tryptone = 10 g/L * 500 mL     // 5 g
length = 12 kb in bp           // 12000 bp

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; in converts where a conversion is written; a field may ask for Quantity<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

buy chassis DH5alpha:
  competence = competent
  efficiency = 1e9 cfu/ug

  cells <- provision DH5alpha
  strain, culture <- transform s from dna into cells

Transform into cells declared naive and you get:

operation 'std.lab.plasmid.transform'
  expects Material<Chassis is competent>, found Material<Chassis is naive>

Culture, Clone, and Plate are 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, so Material[inoculated[Medium]] typechecks under mypy and a bare competent still 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 inert so 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 writing provision twice.

Source breaks

A chassis states competence and efficiency; plating takes a medium instead of an antibiotic; Material<Plate>, Material<Culture>, and Material<Clone> become Material<Medium is inoculated>, Material<Strain is recovered>, and Material<Strain is isolated>.

What's still missing

Competent cell prep is not writable yet: grow, wash, and friends don't lower, and 0054's draw (material quantity and division) is accepted but unimplemented. Making media stops at the planning problem; no facility in the examples advertises manual ArtifactRealization, 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 in a_medium_realizes_without_being_a_plasmid.

If you touch the language: cargo test --workspace doesn't run the Python suite, and python/lab/_native.abi3.so goes stale silently. Run scripts/check-python-sdk.sh after uv run maturin develop --uv.

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.
@marpaia marpaia changed the title Track the state a material is in LB media and competent cells Sep 4, 2026
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.
@marpaia
marpaia merged commit 5416bc2 into master Sep 5, 2026
2 checks passed
@marpaia
marpaia deleted the marpaia/media branch September 5, 2026 01:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant