Building synthetic data pipelines: Ractor
/ 6 min read
Updated:
Part 1: Building with Ractor
This is Part 1 of a 2-part series on building deterministic synthetic patient data.
- Part 1: Building with Ractor (you are here)
- Part 2: Building with Ray
Problem statement and solution (“a” solution not “the solution”)
When you are building a Healthcare RAG, clinical QA, recursive retrieval, multi-hop reasoning and retrieval with evidence grounding the real patient data will be kept at a distance equivalent to 100 feet pole. Real patient data is often inaccessible, difficult to share and will have PHI and PII.
I needed a synthetic patient data generating factory that is deterministic. Your typical thought process is to go for a python script with for loop. The problem with it is
- throughtput
- isolation failure
- pipeline boundary definitions
- concurrency control
- reproducubility
that doesn’t mean that python is not fit (Ray from Anyscale says “hold my beer”). I came across similar problem in my earlier life and that time I used Akka (typesafe) now lightbend have no clue now what and how they are doing. I was heavily on JVM stack at that time and it fit nicely to showcase the pattern. I stumbled upon Ractor framework (Rust) and I had the overall idea about the pipeline and the design of various actors. Claude helped me to steer and write large parts of the code. I was able to build a miniature production-grade factory that someone could clone, run, and, can generate byte-identical patients.jsonl output from the same configuration and seed.
Architecture
Phase 1: Patient pipeline
One actor per stage, each holding an ActorRef to the next, all
speaking PipelineMsg (src/actors/messages.rs). The orchestrator wires it back to front in pre_start, then fires all GeneratePatientBatch messages up front:
Orchestrator -> Profile -> Condition -> Medication -> Reaction -> ClinicalNote -> Guardrail -> Chunking -> Writer -> (BatchWritten back to Orchestrator)- patients.jsonl
- clinical_notes.jsonl
- chunks.jsonl
- summary.json
Each stage transforms the batch into a richer type (PatientProfile ->
PatientWithConditions -> PatientWithMedications -> PatientRecordDraft ->PatientRecord -> PatientOutput), so adding a stage means adding both a message variant and a domain type. Shutdown cascades from the head of the chain so each stage flushes before the writer closes its files - do not cast Shutdown directly to a mid-pipeline actor.
Phase 2: Eval pipeline
Reads the generated patients into an in-memory evaluation context this orchestrator spawns after the patient pipeline finishes. It reads
patients.jsonl back off disk (load_patient_records) to build EvalContext, so evals are grounded in the written data rather than in-memory state.
EvalOrchestrator -> EvalQuery -> EvalWriterand produces:
- evals.jsonl
- ragas_dataset.jsonl
The evaluation pipeline generates seven query classes with ground-truth answers:
- Direct lookup
- Filtered lookup
- Aggregation
- Multi-hop reasoning
- Negative queries
- Comparative queries
- Evidence-retrieval queries
Why use actors?
A conventional for loop could generate records. Actors add value here for three reasons: staged ownership, deterministic concurrency, and inline validation. Staged domain transformations Each pipeline step owns one transformation:
PatientProfile -> PatientWithConditions -> PatientWithMedications -> PatientRecordDraft -> PatientRecordThe actor itself remains a thin orchestration layer. Domain logic lives in pure functions in src/generation.rs, including:
- generate_profile
- assign_conditions
- assign_medications
- simulate_reaction
- generate_clinical_note_text
This separation keeps the data model easy to extend. Adding laboratory results, encounter histories, or claims data means adding a transformation function and inserting a new actor into the chain. It avoids shared mutable state and keeps core generation logic straightforward to unit test.
Deterministic parallelism
The orchestrator submits all batches concurrently. Tokio can schedule them in any order, but scheduling order does not affect generated output because randomness is derived deterministically. The generator uses a two-level RNG tree based on rand_chacha::ChaCha8Rng. Each patient has a stable identity and a stable random stream and there is no thread_rng() in the generation path. Given the same seed and the same config/default.toml, the system produces byte-identical output. That matters for RAG evaluation. If the corpus changes between runs, a difference in retrieval metrics may reflect changed data rather than a better retriever, reranker, embedding model, or chunking strategy.
Guardrails as a pipeline stage
GuardrailActor sits between ClinicalNoteActor and ChunkingActor. It runs validation as a transparent filter rather than embedding checks inside every generator. Per-record checks include:
- PII scans for SSNs, phone numbers, email addresses, ZIP+4 values, and payment card patterns
- Content-policy checks for self-harm, violence, and substance-related content
- Clinical plausibility checks
- Gender condition mismatch detection
- Medication without condition detection
- Age inappropriate medication detection
- Excessive comorbidity detection
⠀Per-batch checks include:
- Condition distribution validation against configured probabilities and tolerance
- PatientID uniqueness validation
Records with Error violations can be dropped when fail_on_error is enabled. Warning records continue through the pipeline and are recorded in guardrail_report.json. The pattern resembles a validation stage in a stream-processing system which has generation that creates candidate records, a guardrails enforce dataset policy, and downstream stages only receive validated output.
While I was navel-gazing about my old repo that had Akka magic and was reminscing about typesafe, lightbend, their licensing don’t know now the state I stumbled upon Ractor that made this exercise magical. Ractor is a strong fit for a reproducible, inspectable, single-machine dataset generator.
-
keeps the architecture explainable The implementation maps directly to the conceptual diagram. A reader can look at src/actors/profile.rs, see ProfileActor, and understand its place in the pipeline without first learning dispatchers, routers, cluster topology, or materialized stream graphs. Eight actors, one orchestrator, and a forward message chain are easy to reason about.
-
provides concurrency without runtime overhead Rust avoids JVM warm-up and garbage-collection pauses. Batch-oriented messages also reduce per-record overhead, making the design appropriate for generating datasets in the 10,000 to 1,000,000 patient range on a developer laptop.
-
preserves pure domain logic Actors should remain mostly stateless transformers. They own configuration and a downstream reference, while the domain logic remains pure and independently testable. This has an important engineering benefit where developers can test clinical-data invariants without starting an actor runtime.
-
make reproducibility natural Each batch receives an explicit seed and each record derives its RNG from that batch seed, concurrent execution does not change results. Message passing provides concurrency without requiring shared random state.
-
makes validation composable Adding GuardrailActor required changing the downstream wiring rather than rewriting every generation stage. This is one of the clearest benefits of a pipeline architecture: policy enforcement becomes a first-class stage.
Where Ractor falls short
Ractor is not a substitute for a distributed, fault-tolerant data platform. The prototype has several limitations like fault tolerance is limited, no backpressure and if failure occurs the recovery is not handled gracefully and you have to build it.
In the next part, we will build with Ray (may not be able to test it with GPUs).
