Skip to content

Sema’s imperative control flow is Python-shaped: if/elif/else, for … in, while, break, continue, return, pass. Two constructs are Sema-specific and matter a lot: with for scoped resources (there are no destructors), and loop … until — a declarative bounded do-until loop built for agents.

match/case also lives here in spirit, but it is large enough to have its own page — see Pattern Matching.

def tier(score: f32) -> str !{}:
if score >= 0.9:
return "hot"
elif score >= 0.5:
return "warm"
else:
return "cold"

A binding made inside an if arm is visible after the block — blocks do not open a new scope. For the concise expression form (only the taken branch is evaluated), use the conditional expression x if cond else y, which chains:

label = "hot" if score > 0.9 else ("warm" if score > 0.5 else "cold")

for iterates any Iterable — lists, dicts, sets, ranges, and Stream[T]:

mut best_score = 0.0
for resource in resources:
if resource.capacity <= 0:
continue
score = fit(incident, resource)
if score > best_score:
best_score = score
for incident in stream: # Stream[T] is Iterable, with backpressure
handle(incident)

Comprehensions are the expression form of the same protocol:

kept = [x for x in xs if p(x)]
by_id = {u.id: u for u in users}
mut i = 0
while i < len(rows) and not done:
process(rows[i])
i = i + 1

while runs under a runaway guard. For agent-style “keep going until a condition holds” loops, prefer loop … until (below) — it is the idiomatic replacement for the hand-rolled while i < max: … if stop: break shape.

Alongside while/for, loop … until is the declarative surface for a bounded agentic loop. The body runs, then the condition is checked — it is a do-until, so it runs at least once:

loop until decision.confidence >= 0.9 max_iters 8:
analysis = breakdown(query, state)
state.facts += fact_extract(search(query_gen(analysis)))
decision = decide(query, state)
  • max_iters <n> bounds the iteration count. Omit it and the loop runs until the condition holds, under the same runaway guard as while.
  • break and continue work inside.
  • It replaces the hand-rolled counter-and-break pattern with something that states its intent and its bound.

A real, runnable use from the research-agent example — iterate until a Bayesian belief crosses a confidence threshold, bounded by the evidence available:

mut belief = Belief(alpha=1.0, beta=1.0, history=[0.5])
mut iters = 0
loop until belief.confidence() >= 0.7 max_iters len(decisions):
belief.update(decisions[iters])
iters = iters + 1

with <expr> as x: is Sema’s single scoped-binding construct. It acquires a resource (an effect), binds it for the block, and deterministically releases it at scope exit — on success, failure, and cancellation, in reverse acquisition order. There are no user destructors or finalizers, because nondeterministic finalization would break replay.

with db.connect(cfg.ledger_dsn) as conn: # acquire is an effect; release is deterministic
rows = conn.query(query)
# `conn` is released here and cannot be referenced afterwards

The same construct expresses three things — policy scoping, model rebinding, and resource lifetimes are all one mechanism:

with policy(NoExecFromGen): # scope a governance policy
run_pipeline(inputs)
with models.writer = local_small: # scoped model rebinding
draft = summarize(article)
with meter as u: # ambient usage metering
answer = write_report(facts)

Key rules:

  • The as-binding is affine and block-scoped — the handle is released at scope exit and cannot escape the block. Referencing it afterwards is a compile error (capture checking).
  • Release is journaled; a release failure is a typed ReleaseError routed to the owning scope, never masking the body’s result.
  • Double-release is impossible by construction.

Because with is what replaces finally, cleanup never lives in a handler — it is owned by the scope. See the error-flow ergonomics table.

match/case is the structural branching construct — it destructures structs, enums, tuples, and regex captures, with exhaustiveness checking and guards:

match choose_assignment(incident, resources):
case Some(assignment): commit(assignment)
case None: defer(incident)

This is how you consume Option/Result and pattern-match enum payloads. The full treatment — refutable vs irrefutable patterns, guards, or-patterns, exhaustiveness, and interpolated literals — is on the Pattern Matching page.