Skip to content

Structured queries in Rust and Python

These examples use Zirium 0.7.0. Repository-relative inputs are in the v0.7.0 checkout; see getting started for installation.

Build queries with methods and predicates, then evaluate them against a semantic document. Rust and Python use the same expression tree and return native values. Queries are read-only; use the textual language for CLI queries and edits.

Textual positional comprehensions construct native Zirql values from explicit scalar projections. Rust callers can match NativeValue::Constructed(report) and inspect report.value() in a textual Query callback before rendering. The typed Rust and Python builders keep their existing operation handles, strings, counts, and maps. The report builders also construct shared values, comprehensions and correlated rows. Native capture builders retain operations and exact SSA values before an explicit scalar projection.

In Python, Document.query() returns the result described by the expression:

import zirium
from zirium.query import ops, op
source = '''
%c = "example.make"() : () -> i32
"example.consume"(%c, %c) : (i32, i32) -> ()
'''
lowered = zirium.parse_text(source).lower_strict()
assert lowered.document is not None, lowered.diagnostics
document = lowered.document
consumers = ops().filter(op("example.make")).users().unique()
selected = document.query(consumers) # list[SemanticOperation]
count = document.query(consumers.count()) # int
assert [operation.name for operation in selected] == ["example.consume"]
assert count == 1

The equivalent Rust expressions use the same methods:

use zirium::query::{ops, op};
let consumers = ops().filter(op("example.make")).users().unique();
let selected = document.query(&consumers)?; // Vec<OperationId>
let count = document.query(&consumers.count())?; // usize

Expressions are immutable and reusable. Each document.query() call evaluates the expression against that document’s current state.

Rust Document::query() uses the baseline registry. For other dialect semantics, use document.query_with_registry(&expression, &registry), passing the registry used to parse and lower the document. Python retains that registry in the document and uses it automatically.

A typed request carries root declarations through composition, erasure and conversion to a report expression. Attach declarations with with_parameters and supply native scalar values at evaluation:

use zirium::query::{ConstructedValue, EvaluationLimits, Located,
ParameterDeclaration, ParameterType, ReportExpr, report};
let query = report(ReportExpr::name("minimum_ops")).with_parameters(vec![
ParameterDeclaration {
name: Located::new("minimum_ops".to_owned(), 0..0),
ty: Located::new(ParameterType::Int, 0..0),
},
]);
let result = query.evaluate_with_parameters(
&document, &registry,
&[("minimum_ops".to_owned(), ConstructedValue::Integer(2))],
EvaluationLimits::default(),
)?;
assert_eq!(result.value(), &ConstructedValue::Integer(2));

Root declarations are visible throughout the builder request. Identical metadata shared by composed expressions is retained once; conflicting metadata reaches admission and fails. A local scope or projection cannot shadow a root declaration. op_expr(ReportExpr::name("operation_name")) checks a supplied string. Rust also has head_expr, tail_expr, attr_expr, defs_at_expr and users_at_expr for shared scalar expressions.

Python report requests use the same declarations and admission:

from zirium.query import ReportExpr, report
query = report(ReportExpr.name("minimum_ops")).with_parameters(
{"minimum_ops": "int"}
)
assert document.query(query, parameters={"minimum_ops": 2}) == 2

Declaration types are "int", "string" and "bool"; inputs are actual Python int, str and bool values. Booleans remain Boolean values rather than integer inputs. Integers must fit signed 64 bits; overflow raises OverflowError. Unsupported value kinds raise TypeError. Strings remain exact data. Conversion, admission, evaluation and native result conversion spend the same request allowance, including input names and string bytes.

Every declaration needs one input; missing or extra inputs and type mismatches fail before native document work. Existing evaluation methods supply an empty input set. For parsed Rust requests, Query::parameters() exposes declarations, Query::evaluate_with_parameters accepts typed scalar pairs, and evaluate_with_parameters_and_budget accepts ParameterInputs::Typed or ParameterInputs::Cli with the caller’s budget. Library emission callbacks retain their existing behavior; callers buffer emissions when a later failure must discard earlier output. The CLI already buffers complete requests.

Text queries also accept shared predicate arguments, for example filter(p => one(p | names) == "example.make") and filter(one(_ | names) == "example.make"). The predicate reference covers constructed filters, destructuring and native candidate scopes. Rust and Python builders keep their typed native predicates; textual arguments require no host-language callable or closure.

Available predicates are op(name), dialect(name), has_attr(name), result_type(spelling), string_attr_eq(name, value), and always(bool). Combine them with & and |; negate with ! in Rust and ~ in Python:

from zirium.query import has_attr
arithmetic = ops().filter(
(op("arith.addi") | op("arith.muli")) & ~has_attr("skip")
)

Python’s and, or, and not do not build predicates. Converting a predicate or query expression to bool raises TypeError with guidance on the supported operators. String arguments are data; they need no query-language escaping.

Navigation methods include defs, users, parent, children, root(predicate), subtree, slice, backward_slice, forward_slice, closure, and reachable. slice() is an alias for backward_slice(). Rust has defs_at(index) and users_at(index) for indexed navigation; Python accepts defs(index) and users(index). Indices select the current operation’s operand or result, respectively.

Navigation preserves order and duplicates. A consumer using the same result twice appears twice in users(). Add unique() when counting distinct operations. union(other), intersect(other), and difference(other) combine operation or string queries. Union keeps first appearance from the left operand, then the right. Intersection and difference deduplicate in left-operand order. Each operand is a complete expression evaluated with the same input.

head(n), tail(n), and reverse() apply to streams and dictionaries; sort() orders strings or dictionary keys. These operate on computed streams and do not avoid parsing the document or executing earlier stages.

Graph traversal has the same structural meaning and dialect limitations as the textual language. In particular, closure() is one scope-retaining dependency step; backward_slice() follows transitive SSA definitions, forward_slice() follows transitive SSA users, and reachable() also follows supported calls and branches. For repeated closure, use:

from zirium.query import input
fragment = arithmetic.fixpoint(input().closure())

A general fixpoint(body) replaces its selection until the ordered stream is unchanged. It preserves duplicate semantics, detects cycles, and obeys work and item limits. For accumulating transitive users, use arithmetic.fixpoint(input().union(input().users())).

MapQuery returns ordered Vec<(String, T)> entries in Rust and a Python dict in insertion order. sort_by_value() stably orders ordinary data values; sort() orders keys. Both return a dictionary query, as do reverse(), head(n) and tail(n). Equal values retain their current order, including after reversal. head_expr and tail_expr accept a ReportExpr bound.

reachable() treats an unregistered operation as a retained leaf by default; it does not follow that operation’s regions, operands, successors, or symbol references. Other queued operations are still processed. Pass strict=True to Document.query to reject such an operation by name. Known malformed calls, missing or external callees, and unsupported registered references remain errors. closure() and fixpoint(input().closure()) always use strict reference semantics.

ops() always starts from every operation in the document. input() starts from the selection supplied to a nested query. At top level, both start from all operations. In map_by, sort_by, and where_exists, the nested input is one operation at a time.

This query maps each function name to a histogram of its represented body:

from zirium.query import input
functions = ops().filter(op("func.func"))
report = functions.map_by(
key=input().string_attr("sym_name").one(),
value=input().children().subtree().names().tally(),
)
histograms = document.query(report) # dict[str, dict[str, int]]

Rust uses references for nested expressions:

use zirium::query::input;
let functions = ops().filter(op("func.func"));
let report = functions.map_by(
&input().string_attr("sym_name").one(),
&input().children().subtree().names().tally(),
);
let histograms = document.query(&report)?; // Vec<(String, Vec<(String, usize)>)>

one() requires exactly one string and returns a scalar. Missing or multiple strings are errors. map_by rejects duplicate keys. String tally() combines equal strings and returns their occurrence counts in first-occurrence order. Operation, type and attribute queries also expose tally(), returning retained dictionaries through the existing erased Rust QueryOutput. Python uses the existing MapQuery wrapper. Non-string keys convert to tagged entry pairs containing actual native values; string keys keep ordinary dictionaries. map_by retains input order. Empty inputs produce empty maps.

Map values can also be operation selections, type or attribute selections, strings, scalar strings, counts, or nested maps. Operations remain usable handles; there is no conversion through JSON. Build larger reports with normal Python or Rust data structures.

To retain operations based on a relationship:

matmuls = ops().filter(op("stablehlo.dot_general"))
with_add_users = matmuls.where_exists(
input().users().filter(op("stablehlo.add"))
)

where_exists accepts an operation query and keeps an input operation when that query returns a nonempty selection. It preserves the original operations, order and duplicates. A complete empty relation rejects its subject; unresolved relations and nested errors fail. The text form is where_exists(users | filter(op("stablehlo.add"))). Relations are pure and are checked even when the outer input is empty.

sort_by, min_by, max_by, min_all_by, and max_all_by accept scalar string or count queries. For example, functions.sort_by(input().children().count()) sorts by direct child count. Sorting is stable; single extrema retain the first tie, and all-extrema retain every tie. Empty extrema retain an empty native selection with its admitted kind, element declaration and owner.

Expression Rust result Python result
Operation query Vec<OperationId> list[SemanticOperation]
names() Vec<String> list[str]
string_attr(name) Vec<String> list[str]
result_types(), operand_types() Vec<TypeId> list[SemanticType]
attributes(name) Vec<AttributeId> list[SemanticAttribute]
String query followed by one() String str
count() usize int
tally() Vec<(String, usize)> dict[str, int]

Type and attribute queries expose spellings() for retained MLIR text. string_attr(name) decodes string attributes, omits missing attributes, and reports an error for a present non-string attribute or undecodable UTF-8. Use attributes(name).spellings() to inspect those retained spellings.

For example, a configured call target is a symbol-reference attribute, so read its retained MLIR spelling rather than treating it as a string attribute:

calls = ops().filter(op("vendor.invoke"))
targets = document.query(calls.attributes("target").spellings())

calls.string_attr("target") is appropriate only when target is an MLIR string attribute; it reports an error for a symbol reference such as @callee.

Rust exposes methods according to the result type: a string query has no users method. Python exposes corresponding classes and type declarations, including QueryExpr[T] for annotating reusable expressions.

Evaluated selections keep their membership, while their handles refer to live objects and reflect later edits. Accessing deleted or invalidated objects raises a stale-handle error. Scalars and strings keep their evaluated values.

Select first, then use the existing edit transaction:

selected = document.query(ops().filter(has_attr("analysis.tag")))
with document.edit() as edit:
for operation in selected:
edit.remove_attribute(operation, "analysis.tag")

Python accepts per-evaluation limits:

selected = document.query(
consumers, max_work=1_000_000, max_items=100_000, strict=True
)

Rust accepts them on the expression:

use zirium::query::{EvaluationLimits, EvaluationOptions};
let selected = consumers.evaluate_with_options(
&document,
&registry,
EvaluationOptions { strict_unknown_references: true },
EvaluationLimits { max_work: 1_000_000, max_items: 100_000 },
)?;

The defaults are 10 million work units and 1 million items. Limits cover expression nodes, scope and type preparation, stage inputs, visited predicate nodes, traversal work, and nested result sizes. They are not byte or wall-clock bounds. Boolean evaluation short-circuits; skipped branches consume no evaluation work.

Nested expressions and predicates have a 64-level limit; flat pipelines, boolean chains, and set chains do not consume a level per term. Excessive nesting is reported when the expression is evaluated.

Ordinary and diff programs use Zirql’s statement order, immutable bindings, groups, sets and composition steps. Ordinary text and diff text programs use zirql::read_program for function collection, parameters, pattern lets and separators. The ordinary adapter preserves the public parser’s statements, final expression and parameter positions; typed builders continue to construct that frontend model directly. Zirium retains legacy bindings, do and native output policy. The legacy binding reader accepts a single = and leaves == to the expression reader. Queries parsed with Query::parse("let n=1; n == 1") or Query::parse("let n=1; report n == 1") both evaluate to the Boolean true.

Filter, match, ordering, mapping, existence, fixed-point and path bodies prepare through the shared runner. Each selector and existence relation receives one subject; a fixed point receives the complete selection. Match clauses keep their original input and earlier named captures. Native hooks retain MLIR identity, result shape and the diff side. Typed diff fixed-point bodies and navigation use the same active allowance. The textual Query API also accepts evaluate_with_program_limits; --max-results exposes its aggregate allowance in the CLI. Typed expression APIs retain their existing limits and native values. See query language limits for the aggregate units and the caller-owned printing boundary.

Text parsing returns query::QueryError with an owned String message and a query byte position. query::parser::Diagnostic also owns its message; it is Clone and no longer Copy. Its message(&self) -> &str and range(&self) accessors borrow the diagnostic. Direct QueryError construction supplies a String, for example message: "invalid query".into(). Ownership lets detailed shared errors survive parsing without replacing them with a generic message.

Evaluation returns EvaluationError in Rust and raises ValueError in Python. Python rejects invalid argument types with TypeError. Evaluation runs in Rust with shared document access and without holding the Python interpreter lock; queries cannot contain Python callbacks, edits, or emitters.

Typed diff builders start with changes(). A ChangeQuery accepts ordinary and change predicates; .before() and .after() return side-scoped operation queries. Rust returns DiffOperationSelection rather than bare operation IDs. Python resolves those selections to wrappers backed by immutable snapshots. Use diff_op_input() to build a relative fixed-point body, for example changed_ops.fixpoint(diff_op_input().slice()). Diff value projections include names(), attr(...), result_types(), and operand_types(). DiffProgram::evaluate_with_parameters accepts typed scalar inputs; evaluate_with_parameters_and_budget accepts zirql::ParameterInputs and one request budget. parameters() exposes the parameter declarations. Native function signatures use Change, BeforeOperation and AfterOperation, preserving comparison and side admission. See semantic diff for a parameterized text report and the native builder examples.

OpQuery::project returns a ReportQuery whose native result is ConstructedReport. bound_ops names a saved native selection or a projection subject. ReportExpr contains the shared value tree; construction reads no document and accepts no query-source strings or evaluator callbacks.

Textual reports and typed builders produce Zirql’s located Value, Projection and composition steps directly. Each request registers its functions with one PreparedFunctions unit, including requests with no function declarations. prepare_expression checks the written bodies and retains their result kinds; evaluate_expression supplies the current bindings, relative input and cumulative budget. Zirium’s native stages retain their MLIR payloads and ownership checks.

Native tuples, capture records and positional sequences expose ordinary data to the same infix admission as constructed report values. Equality checks compatible underlying types before returning an unequal result; relations require numbers. Explicit optional and empty sequence declarations survive native wrapping. Admission of deferred values occurs only when that comparison executes. MLIR handles retain exact native identity and require the existing explicit scalar projections where needed.

Projection fields are checked before enumerating subjects, so an empty selection retains its declared row schema and still rejects invalid fields. The shared projection contract owns field scope and scalar construction; the builder below supplies the native operation selection.

The ordinary textual report frontend projects saved scalar streams and counts through NativeQuery. Function-capable bodies use NativeValue to retain admitted handles as well as scalar results. Both forms execute in the same prepared unit; report-only positions still require an explicit scalar projection of native handles.

Core collection steps handle count, bounds, unique and tally, and core ordering handles sort. They preserve the admitted native or constructed shape. Zirium supplies exact operation, type and attribute identities and retains empty native selections with their admitted kind and element declaration. Native navigation keeps its document ownership checks. A plain native count or selection keeps its work and stream limits; attaching a prepared unit does not require an aggregate result allowance.

use zirium::query::{ReportExpr, bound_ops, op, ops};
let f = bound_ops("f");
let rows = ops().filter(op("func.func")).project("f", [
("name", ReportExpr::from(f.attr("sym_name").one())),
("operations", ReportExpr::from(f.children().subtree().count())),
("matches", ReportExpr::from(f.children().subtree().filter(op("arith.addi")).count())),
]);
let native_report = document.query(&rows)?;
let value = native_report.value();

StringQuery, scalar-string queries, CountQuery and ReportQuery convert explicitly to ReportExpr. Scalar literals, arrays, records, field/index access, generator clauses, reducers and requirements all use the shared evaluator. ReportGenerator constructs in or immutable binding clauses. ReportExpr::scope binds values in order and returns one head through these same clauses. op_expr accepts a checked string expression while the ordinary op("name") builder remains available. Python converts constructed lists, tuples and sets to ordered lists. Ordinary string-key dictionaries become Python dictionaries; general dictionaries use {"kind": "dictionary", "entries": [{"key": key, "value": value}]} so nested collection keys do not require Python hashability. The Rust value retains its collection kind until conversion. The Python builders retain their existing constructors; general set and dictionary literals are available through the textual language and the shared Rust value API. attr(name) follows the textual attribute-spelling projection; string_attr(name) continues to require and decode a string attribute.

Typed constructed requests select aggregate limits automatically. evaluate_with_program_limits accepts explicit limits, and evaluate_output_with_budget retains the caller’s remaining allowance for conversion. Native handles stay document-owned; report values carry their query location. Render explicitly with ConstructedReport’s budgeted JSON methods, or call QueryOutput::to_json_with_budget(document, budget) for mixed native reports. Both use Zirql’s shared ordinary codec and container traversal. Nominal members and keyed arrays use tagged fallback, retaining member names and complete axes. Native descriptors and document envelopes remain Zirium projections. Python converts constructed data through that same codec to ordinary Python lists, dictionaries and scalars; native operation, SSA, type and attribute results retain their document-owning wrappers.

Python mirrors this surface through zirium.query:

from zirium.query import ReportExpr as E, bound_ops, op, ops, report, require
f = bound_ops("f")
rows = ops().filter(op("func.func")).project("f", {
"name": f.attr("sym_name").one(),
"operations": f.children().subtree().count(),
"matches": f.children().subtree().filter(op("arith.addi")).count(),
})
result = document.query(report(require(True, E(rows), "stop")))

Document.query returns Python bool/int/float/string/list/dict data directly, with record insertion order. ReportExpr also accepts scalar-query wrappers and nested list/dict construction. ReportGenerator and ReportExpr.comprehension supply value generators; .sum(), .length(), .forall() and .exists() reduce arrays. ReportExpr::group_by("field") in Rust and .group_by("field") in Python group positional records through the shared evaluator. Groups contain key and items, with first-seen keys and original row order and duplicates. The grouping language reference defines supported keys and failures.

Reuse the function rows above, whose matches field counts arith.addi operations. Rust returns a native ConstructedReport:

let grouped = document.query(&zirium::query::report(ReportExpr::from(rows).group_by("matches")))?;
let value = grouped.value();

The corresponding Python call returns ordered list/dict data:

result = document.query(report(E(rows).group_by("matches")))

With the function fixture, both obtain key 1 with double/mix, then key 0 with plain. Python conversion spends the remaining request budget before allocating collections. Ordinary operation/type/attribute results retain their existing semantic wrappers.

The methods in this section accept shared expressions and patterns; they do not execute Rust or Python callbacks. Functions and parameter declarations compose with the existing report metadata.

import zirium
from zirium.query import ReportExpr as E, ReportGenerator as G, ReportPattern as P
from zirium.query import op, ops, report
lowered = zirium.parse_text('"example.seed"() : () -> ()').lower_strict()
assert lowered.document is not None
document = lowered.document
n = E.name("n")
selected = E.comprehension(
n, [G.in_pattern(P.present(P.name("n")), [None, 1, 2], case=True)]
)
assert document.query(report(selected.map(P.name("n"), n + 1).windows(2))) == [[2, 3]]
assert document.query(report(E([1, 2]).flat_map(P.name("n"), [n, n]))) == [1, 1, 2, 2]
rows = E([{"id": 1}, {"id": 1}, {"id": 2}])
assert document.query(report(rows.runs_by("id").map(
P.name("run"), E.name("run").field("items").length()
))) == [2, 1]
assert document.query(report(E({"id": 1, "amount": 2}).with_fields({"amount": 3}))) == {
"id": 1, "amount": 3
}
assert document.query(report(E([{"id": 1}]).align([{"id": 2}], "id").map(
P.name("entry"), E.name("entry").field("key")
))) == [1, 2]
native_rows = E([{"operation": E.native(ops().filter(op("example.seed"))), "amount": 1}])
result = document.query(report(native_rows.map(
P.name("row"), E.name("row").with_fields({"amount": 2})
)))
assert result[0]["operation"].name == "example.seed"
assert result[0]["amount"] == 2

ReportExpr.native(query) retains native operations, SSA values or captures without converting them to JSON. Python results keep document-owning wrappers. An ordinary native selection keeps its existing kind; constructing an array of native records produces positional report data. Rust exposes the same methods: map(pattern, body), flat_map(pattern, body), windows(size), runs_by(field), with_fields(fields) and align(after, field). ReportGenerator::in_pattern accepts a located Pattern, a source, an optional filter and the selective case flag. Existing in_values generators remain strict.

Zirql enumerates children and maps payloads. Zirium’s normalization callback handles retained expression wrappers; native-root discovery decomposes only host stages. Native type conversion, operation ownership, coordinates and read-only admission remain in Zirium.

The typed builder uses the same traversal as the short command:

use zirium::query::{ops, op, PathEdge};
let paths = ops().filter(op("arith.muli"))
.paths(PathEdge::Users, op("func.return"), 2);
let native = document.query(&paths)?;

paths returns CaptureQuery; native is CaptureMatches. On examples/cli/function-report.mlir, its one row retains the native mix operations [arith.muli, arith.addi, func.return]. The row’s source and sink are singleton selections, and steps is NativeValue::OperationSequence. It remains a sequence for a zero-edge witness. Native IDs and source ranges remain available before rendering.

Python supplies the supported edge name explicitly:

from zirium.query import ops, op, capture_ops
paths = ops().filter(op("arith.muli")).paths(
"users", to=op("func.return"), max_depth=2
)
native = document.query(paths)
rows = document.query(paths.project("p", {"steps": capture_ops("p", "steps").names()}))

Python path rows are dictionaries with native SemanticOperation endpoints and a list of native SemanticOperation steps. Those wrappers retain their document owner. Ordinary singleton capture fields keep their existing wrapper, including fields named steps. Negative or overflowing Python bounds fail before document evaluation.

The target accepts ordinary predicates. Root parameter declarations survive path reuse, erasure, nesting and projection; dynamic target arguments use the existing typed parameter admission. PathEdge::Users enumerates direct SSA users in result/use-site order. The bound counts edges, matching roots produce [source], and equal-length alternatives follow that order. No witness within the bound does not prove global disconnection.

Paths use the active aggregate budget for traversal, copies, projection and Python conversion. Library callers can retain an explicit Budget::for_program(ProgramLimits { ... }); EvaluationLimits keeps its per-stream and work ceilings. Paths remain read-only and are unavailable on diff queries.

ReportExpr::sort(descending) and sort_by(field, descending) stably order positional scalars or records. Pass false for ascending and true for descending; ties keep input order in both directions. Python provides the same methods with keyword-only descending=False. The four extrema methods are .min(), .max(), .min_by("field") and .max_by("field"). Empty input returns absence (None in Python), and the first tied input wins. Original rows stay intact; the builders retain function and parameter declarations.

use zirium::query::{ReportExpr as E, report};
let rows = E::array([
E::record([("id", E::string("A")), ("score", E::integer(2))]),
E::record([("id", E::string("B")), ("score", E::integer(1))]),
]);
let ordered = report(rows.clone().sort_by("score", false));
let best = report(rows.max_by("score"));
let empty_default = report(E::array([]).min().default(E::integer(0)));
from zirium.query import ReportExpr as E, report
rows = E([{"id": "A", "score": 2}, {"id": "B", "score": 1}])
ordered = report(rows.sort_by("score", descending=True))
best = report(rows.max_by("score"))
empty_default = report(E([]).min().default(0))

The shared ordering entry defines structural keys, selector errors, empty results and ties. Use an explicit lookup/default field when missing storage should supply a key. Native OpQuery and map builders retain their existing selector API and document ownership. The MLIR recipe runs the same constructed report through the text frontend.

ReportExpr::null() constructs absence in Rust. The direct .occurs(), .absent(), .lookup(key), .has_key(key) and .default(fallback) methods build the same nodes as the textual language. They retain required parameter metadata from both operands, including skipped fallbacks.

use zirium::query::{ReportExpr as E, report};
let info = E::record([("id", E::integer(4)), ("stored", E::null())]);
let query = report(E::record([
("value", info.clone().lookup(E::string("missing")).default(E::integer(8))),
("stored", info.clone().has_key(E::string("stored"))),
("info", info),
]));
let result = document.query(&query)?;

Python accepts None in report scalars, lists and dictionaries and returns it for stored absence. The corresponding methods accept ordinary report inputs:

from zirium.query import ReportExpr as E, report
info = E({"id": 4, "stored": None})
result = document.query(report({
"value": info.lookup("missing").default(8),
"stored": info.has_key("stored"),
"missing": info.has_key("missing"),
"info": info,
}))
assert result == {
"value": 8, "stored": True, "missing": False,
"info": {"id": 4, "stored": None},
}

Lookup misses return absence, while wrong key/container types and strict missing access still fail. Default evaluates its fallback only for absence; zero, false and empty containers remain present. Scope, purity and known-type checks still cover both operands. See the textual optional report for inline/file usage and the reusable report fixture with a native projection. Required external parameters continue to accept int/string/bool only; passing None as a parameter fails before execution. Optional report values add no new MLIR semantics or edit format.

SsaQuery and CaptureQuery retain native SSA values and capture rows. OpQuery::operand returns one exact ValueId; CaptureQuery returns CaptureMatches, whose rows() expose ordered native records and whose range() retains the query location. Neither result is a constructed scalar value.

Build the native capture example with concrete immutable clauses:

use zirium::query::{bound_ops, bound_ssa, capture_ops, matches, op, ops,
same_value, ReportExpr as E, report};
let captures = matches()
.from("add", &ops().filter(op("arith.addi")))
.bind("lhs", &bound_ops("add").operand(0))
.bind("rhs", &bound_ops("add").operand(1))
.where_(&same_value(&bound_ssa("lhs"), &bound_ssa("rhs")))
.build();
let native = document.query(&captures)?;
let add = capture_ops("m", "add");
let rows = captures.project("m", [
("function", E::from(add.root(op("func.func")).attr("sym_name").one())),
("operation", E::from(add.names().one())),
]);
let full = report(E::scope(
[("rows", rows.into())],
E::record([
("matches", E::name("rows")),
("count", E::name("rows").length()),
]),
));
let result = document.query(&full)?;

On the original function fixture, native contains one row with fields add, lhs and rhs; the SSA handles are equal. result contains only the double addition and count 1. bound_ops and bound_ssa refer to earlier captures. capture_ops(subject, field) and capture_ssa(subject, field) select a field from a projection’s native record and check its promised kind, even when the capture collection is empty. Match guards combine with &, | and ! in Rust.

Python mirrors these builders with .from_ and .where_:

from zirium.query import (
ReportExpr as E, bound_ops, bound_ssa, capture_ops,
matches, op, ops, report, same_value,
)
captures = (
matches()
.from_("add", ops().filter(op("arith.addi")))
.bind("lhs", bound_ops("add").operand(0))
.bind("rhs", bound_ops("add").operand(1))
.where_(same_value(bound_ssa("lhs"), bound_ssa("rhs")))
.build()
)
native = document.query(captures)
assert native[0]["lhs"] == native[0]["rhs"]
add = capture_ops("m", "add")
rows = captures.project("m", {
"function": add.root(op("func.func")).attr("sym_name").one(),
"operation": add.names().one(),
})
full = report(E.scope(
{"rows": rows},
{"matches": E.name("rows"), "count": E.name("rows").length()},
))
result = document.query(full)

Python returns a list of dictionaries in capture-field order. Operation fields are SemanticOperation wrappers and SSA fields are existing SemanticValue wrappers, retaining their owning document. A standalone SsaQuery returns one SemanticValue. Match guards combine with &, | and ~; converting one to bool raises TypeError.

Capture builders retain root parameter declarations through reuse, erasure, nesting and projection. Attach declarations with CaptureQuery.with_parameters and supply values through the existing evaluation methods. Parsing, matching, native copies, scalar projection and Python conversion spend one request allowance. Invalid scope, native types, fields and effects fail before enumeration, including for empty sources and unchosen Boolean branches. operand requires one operation and a resolved operand; same_value compares the full result or block-argument identity rather than its defining operation. Native captures remain read-only and are unavailable on the diff surface.

Rust exposes shared Pattern, PatternKind and Located values. ReportExpr::tuple preserves tuple shape; ReportExpr::block takes ordered (Pattern, ReportExpr) bindings and a final value. ReportExpr::value_match takes a subject and ReportMatchArm { pattern, guard, value } arms. FunctionDefinition::with_patterns accepts PatternParameter declarations; FunctionDefinition::new retains its name-only parameter signature. The complete Rust example uses a tuple parameter and a tail call through a local block and value match.

Python’s report builders use reusable ReportPattern and ReportMatchArm owners. They accept the existing report inputs and run through Document.query:

from zirium.query import ReportExpr as E, ReportPattern as P, ReportMatchArm as Arm
from zirium.query import report
cost = E.name("cost")
query = report(E.value_match(E({"cost": 4}).lookup("cost"), [
Arm(P.absent(), {"kind": "absent", "cost": 0}),
Arm(P.present(P.name("cost")), {"kind": "large", "cost": cost}, guard=cost > 10),
Arm(P.present(P.name("cost")), E.block(
[(P.name("answer"), cost + 1)],
{"kind": "small", "cost": E.name("answer")},
)),
Arm(P.wildcard(), {"kind": "fallback", "cost": 0}),
]))
assert document.query(query) == {"kind": "small", "cost": 5}

P.tuple([...]), P.record({...}, ignore_rest=True), P.literal(value) and E.tuple([...]) cover the same structural forms as the text reader. pattern.located(start, end) retains source coordinates for diagnostics. E.conditional(condition, selected, otherwise) builds a conditional value. Patterns, arms and expressions refuse Python truth-value conversion. Queries can be reused against another current document; they retain no active budget. Python has no function-definition builder or textual-program entry point.

The language guide describes exact shapes, scope and ordered guards. All branches receive static checks, while runtime evaluation visits only demanded values. Native capture enumeration and operation/SSA ownership keep their existing boundaries.

Use Zirium’s builders below to define and call typed functions while retaining checked MLIR operation handles. The Zirql embedding guide provides more detail on the shared value, pattern and function nodes.

FunctionDefinition uses shared FunctionParameter, FunctionType and value nodes. It constructs declarations directly; no generated source is parsed. Attach all definitions to the submitted QueryExpr with with_functions. Composition shares identical definitions and retains conflicting definitions for preflight to reject.

use zirium::query::{
FunctionDefinition, FunctionParameter, FunctionType as T,
FunctionLimits, Located, ReportExpr as E, ValueBinaryOperator as B, report,
};
let parameter = |name: &str| FunctionParameter {
name: Located::new(name.to_owned(), 0..0),
declaration: T::Integer,
};
let accumulate = FunctionDefinition::new(
"accumulate", vec![parameter("n"), parameter("total")], T::Integer, vec![],
E::conditional(
E::name("n").binary(B::Equal, E::integer(0)),
E::name("total"),
E::call("accumulate", [
E::name("n").binary(B::Subtract, E::integer(1)),
E::name("total").binary(B::Add, E::name("n")),
]),
),
)?;
let query = report(E::call("accumulate", [E::integer(1000), E::integer(0)]))
.with_functions(vec![accumulate])
.with_function_limits(FunctionLimits { active_depth: 1 });

Run the complete library example:

Terminal window
cargo run -p zirium --locked --example typed_functions

It evaluates the same native tree as the CLI, then the accumulator, and prints 8 and 500500. Evaluation and explicit JSON conversion use one budget.

FunctionType::Native(FunctionNativeType::Operation) and SSAValue describe checked document-owned IDs. E::native(&query) retains a typed query operand; E::operation(id) and E::ssa_value(id) supply explicit handles checked on function admission. E::operation_sequence(ids) preserves sequence shape, including zero or one operation. E::direct_children and E::cost_annotation construct ordinary Call nodes using the same native definitions as the textual reader. function_call::<T> starts a typed native query through the existing QueryResult conversion; report(E::call(...)) returns ConstructedReport for scalar/report values.

Functions never retain the document, caller bindings, result history or active budget. Zirql’s PreparedProgram prepares the canonical lowered statements and all function bodies before execution. It advances parameter and binding scope, then runs entries against the request’s current context. Relative native groups retain their native result shapes and enclosing effect allowance. Zirium retains MLIR kind admission, document ownership, reserved native names and emission. Native callback arguments retain a shared owner after a function frame returns. Passing or inspecting unchanged constructed data shares its immutable owner; constructing a new row or rendering output still pays for the produced data. External parameters keep their existing int/string/bool transport. Python keeps its existing query builders and native result conversion; there is no Python function builder API.

ReportExpr::loop_, break_ and continue_ construct shared control nodes. fold and scan take items, initial state and a named step. queue, typed_queue, pop_front, push_back_all, identity_set, is_in and insert expose the same computational operations as the textual reader. Use FunctionType::Queue(Box::new(element)) and FunctionType::IdentitySet(FunctionNativeType::Operation) in signatures. typed_queue takes a SignatureType for an explicit empty element contract. All builders merge their operands’ function and parameter metadata. The native convenience builders direct_children, cost_annotation, same_operation and user_info use ReportExpr::call; both arguments of same_operation contribute metadata and remain separate ordered arguments. See the native signatures and limitations.

The complete typed reachability example constructs destructured native parameters, optional pop arms, a loop and explicit user_info/same_operation calls without generating query text:

Terminal window
cargo run -p zirium --locked --example typed_worklists

It prints the same zero-edge, reachable and disconnected results as the text program. evaluate_output_with_budget retains native computational results. Supply a retained result with ReportExpr::native_input(output) for continued library use; handles and computational context are checked against the current document. Use one active budget for evaluation and explicit conversion. Ordinary native tuples and positional sequences can be converted to JSON. A queue or identity set anywhere inside a value produces a located conversion refusal.

The shared runtime retains immutable queue/set roots and scan snapshots. Advancing a queue or inserting an identity preserves previously retained values. The host adapter supplies exact document owner/revision and checked object identity, plus complete direct SSA-user adjacency; it runs no second loop or worklist evaluator. Python keeps its existing query builder API.

Rust ReportExpr and Python ReportExpr provide contains, starts_with and ends_with. Each method accepts another report expression; Python also accepts ordinary string arguments. Matching is exact and case-sensitive, empty searches match, and both operands must evaluate to strings. Unicode text is compared without normalization. Scans and failure diagnostics use the query budget.

use zirium::query::{ReportExpr as E, report};
let query = report(E::string("Ångström").contains(E::string("ström")));
from zirium.query import ReportExpr as E, op, ops, report
label = E(ops().filter(op("func.func")).attr("sym_name").one())
result = document.query(report(label.starts_with("work")))

Select exactly one function before using one(). Use ReportGenerator to filter several projected names; the string-matching example shows the equivalent text program and Unicode controls. Domain name extraction continues to use native projections such as attr("sym_name") and names.

The adapter uses Zirql’s budgeted declaration copying and structural native inspection. Copies retain empty sequence element contracts and field order; Zirium stores the copied declaration in Arc and retains native owner checks. Shared scalar type-fact admission checks primitive arguments and predicates. A declared scalar can supply an incomplete actual fact during preparation; bare unknown facts remain a refusal. Runtime values still pass the existing native or constructed admission checks.

Count and index arguments use Zirql’s located nonnegative integer conversion, including platform overflow refusal, with Zirium’s argument diagnostics. MLIR kind conversion, native handles, source coordinates and domain admission remain in Zirium. These are Rust adapter APIs, not additional Rust/Python query syntax. See the shared API contract.

Textual reports evaluated through the Rust library use the same shared Markdown renderer as the CLI. Collect each QueryOutput::Text after the query succeeds:

use zirium::{dialect::DialectRegistry, query::{Query, QueryOutput}};
let query = Query::parse(
"match { from subject in (input | filter(op(\"func.func\"))); } | markdown;"
)?;
let mut output = Vec::new();
query.evaluate(&mut document, DialectRegistry::baseline(), |_, value| {
if let QueryOutput::Text(text) = value { output.extend_from_slice(text.as_bytes()); }
Ok(())
})?;

document is the lowered MLIR document described above. Commit the collected bytes only after evaluate succeeds, so a later error discards the whole report. The CLI recipe uses the same capture rows. Zirql owns table layout, escaping and budgets; Zirium retains operation descriptors, source output and emitter policies.

Rust ReportExpr and Python ReportExpr expose parse_int(), parse_float() and to_string(). Each forwards to the common unary evaluator; the host supplies no conversion implementation. For example, Python can build a label from an explicit native count projection:

from zirium.query import ReportExpr as E, ops, report
query = report({
"count": E(ops().count()).to_string(),
"quantity": E("002").parse_int(),
"price": E("1.25e1").parse_float(),
})
result = document.query(query)

In Rust, the corresponding scalar builders are ReportExpr::from(ops().count()).to_string(), ReportExpr::string("002").parse_int() and ReportExpr::string("1.25e1").parse_float(). See the shared contract and textual report recipe.

Rust ReportExpr exposes filter(pattern, body), count(), length(), unique(), tally(), head(count), tail(count), reverse() and check(operand, message). Bounds and optional check operands/messages are report expressions; use None for omitted Rust check arguments. These methods build the same prepared value nodes as text and merge parameter/function metadata. Arrays, sets and dictionaries retain their kind, order and declared empty schemas. Dictionary predicates receive values, and keys stay attached to retained entries. Native OpQuery operations retain their existing document ownership and subjects.

unique() retains the first occurrence of each identity. tally() returns a dictionary of counts whose keys follow first-occurrence order. Constructed values use structural identity; native leaves retain document ownership and identity. Both methods retain parameter and function declarations, so their results can be used with other report methods.

Python mirrors the same operations:

import zirium
from zirium.query import ReportExpr as E, ReportPattern as P, report
document = zirium.parse_text('"test.seed"() : () -> ()').lower_strict().document
assert document is not None
selected = E([3, 1, 3]).filter(P.name("n"), E.name("n") > 1)
result = document.query(report(selected.head(2).reverse().check(2, "two rows")))
assert result == [3, 3]
assert document.query(report(E(["load", "add", "load"]).unique())) == ["load", "add"]
assert document.query(report(E(["load", "add", "load"]).tally())) == {"load": 2, "add": 1}
assert document.query(report(E({"a": 1, "b": 3}).filter(P.name("v"), E.name("v") > 1))) == {"b": 3}

count and length return cardinality, including dictionary entries and present keyed cells. Other selection operations require explicit positional/entry conversion of keyed arrays. Tuple and string values are refused. Runtime checks and native Unknown/completeness rules follow the shared collection reference.