Understanding Rust code – rig::pipeline
From the rig.rs docs: “This module defines a flexible pipeline API for defining a sequence of operations that may or may not use AI components (e.g.: semantic search, LLMs prompting, etc).”
The pipeline is a bunch of “ops” or operations that must implement the Op trait.
rig::pipeline
“The Op trait requires the implementation of only one method:
call
, which takes an input and returns an output.”
Rig.rs official docs and examples:
https://docs.rs/rig-core/latest/rig/pipeline/index.html
https://docs.rig.rs/docs/4_concepts/4_chains
https://github.com/0xPlaygrounds/rig/blob/feat/agentic-chains/rig-core/examples/chain.rs
Understanding a pipeline and chain
“The pipeline API also provides a parallel! and macro for running operations in parallel.”
This code is a demo of using pipeline::new()
use rig::pipeline::{self, Op};
#[derive(Debug)]
struct Order {
subtotal: f32,
discount: f32,
tax: f32,
total: f32,
}
impl Order {
fn new(subtotal: f32) -> Self {
Self {
subtotal,
discount: 0.0,
tax: 0.0,
total: 0.0,
}
}
}
#[tokio::main]
async fn main() {
// Define the pipeline
let pipeline = pipeline::new()
// op1: apply a 10% discount to the order subtotal
.map(|mut order: Order| {
order.discount = order.subtotal * 0.1;
order
})
// op2: apply a 20% tax to the order subtotal after discount
.map(|mut order: Order| {
order.tax = (order.subtotal - order.discount) * 0.2;
order
})
// op3: calculate the total (subtotal - discount + tax)
.map(|mut order: Order| {
order.total = order.subtotal - order.discount + order.tax;
order
})
// op4: format the final result with a summary
.map(|order: Order| {
format!(
"Subtotal: ${}, Discount: ${}, Tax: ${}, Total: ${}",
order.subtotal, order.discount, order.tax, order.total
)
});
// Create an order with a subtotal of $100
let order = Order::new(100.0);
// Call the pipeline synchronously
let result = pipeline.call(order).await;
// Assert the result is as expected
assert_eq!(
result,
"Subtotal: $100, Discount: $10, Tax: $18, Total: $108"
);
}
This pipeline pattern in Rust, especially in the context of rig
, allows you to decouple your logic into smaller, reusable operations.
** If you were just doing simple sequential transformations using four closures in a single function would actually suffice.
So why would you use a pipeline then?
1. Modularity and Reusability:
The pipeline approach shines when you need to repeatedly apply similar operations in different parts of your code, or when you want to change the order of operations easily without touching the core logic. If you have a lot of transformations that can be swapped in and out or reused elsewhere, pipelines provide a cleaner way to compose and manage these operations.
For example, you could have different operations for applying a discount, calculating tax, formatting output, etc., and reuse these operations across various parts of your application, possibly with slightly different configurations (e.g., different discount rates or tax percentages).
2. Readability and Composition:
While a single function with multiple closures works fine for small tasks, pipelines are intended for larger, more complex workflows. The visual nature of pipelines, especially in a larger context, provides a way to think of your transformations as a series of composable operations, like nodes in a graph. It makes it easier to visualize the flow of data and transformations, especially when dealing with parallel or conditional operations.
3. Separation of Concerns:
By using a pipeline, you separate the concerns of the individual operations, which makes it easier to test, debug, and maintain each piece independently. You can modify, add, or remove operations without affecting the overall structure, whereas with a function containing multiple closures, changes in one operation may ripple through the function and make it harder to manage.
4. Parallel Execution:
Another key advantage of pipelines is that they can manage parallel operations efficiently. With rig
‘s parallel!
macro, you can easily run operations concurrently, which is trickier to manage manually in a function with multiple closures. If you were to manually manage async or parallel operations, you’d have to handle them with additional code, such as spawning tasks or managing futures, which the pipeline abstraction takes care of.
5. Future Expandability:
In a more complex scenario, you might find yourself adding operations with varying complexity. You could later introduce operations that need to perform IO, call APIs, or interact with databases. The pipeline allows you to easily switch to a more advanced implementation (such as incorporating async operations or integrating AI components) without refactoring your entire code base.
To summarize:
While a simple closure chain inside a function will absolutely suffice for small tasks, the pipeline shines when:
- You need to easily manage multiple operations in a modular, reusable way.
- Your transformations might grow in complexity over time.
- You want to enable parallelism.
- You want a flexible, extendable architecture for a growing project.
The rig::pipeline::Op and rig::pipeline::TryOp traits implement define the pipeline interface which includes generic ops (e.g. : map, map_ok, then, and_then, unwrap_or_else.