Architecture Review
This document captures the findings from a comprehensive architecture review performed after the initial implementation of all crates. It is kept for the reasoning, not as a description of the tree.
The tree as it was
Section titled “The tree as it was”907 tests (582 Rust + 325 Python), all passing, clippy clean.
soma-core Foundation types, tracking schema, graph renderingsoma-macros #[derive(SomaFilter)] proc macrosoma-compiler Graph → ExecutionPlan compiler, schedulersoma-runtime Executor, caches, samplers, StudyRunner, run trackingsoma-coordinator Worker registry, routing, heartbeat monitoringsoma-worker Worker protocol and daemonsoma-memory KnowledgeBase (+ ChronosVector)soma-agent Research agent loopsoma-mcp MCP serversoma-python PyO3 bindingssoma Facade crate re-exporting the workspaceIdentified Issues
Section titled “Identified Issues”Priority 1: Critical Design Issues
Section titled “Priority 1: Critical Design Issues”1.1 Filter Trait Mixes Concerns
Section titled “1.1 Filter Trait Mixes Concerns”Current: The Filter trait owns both computation (fit/forward) and caching (config_hash).
pub trait Filter: Send + Sync { fn config_hash(&self) -> CacheKey; // caching concern fn fit(&self, x: &Value, ...) -> Result<Value>; // computation fn forward(&self, x: &Value, state: &Value) -> Result<Value>; // computation fn meta(&self) -> FilterMeta; // metadata}Problem: Cache key computation is not a computation concern. It forces all filter implementations to know about CacheKey, even if they don’t use caching.
Proposed Fix: Split into composable traits:
trait Compute: Send + Sync { fn fit(&self, x: &Value, y: Option<&Value>) -> Result<Value>; fn forward(&self, x: &Value, state: &Value) -> Result<Value>;}
trait Describable { fn meta(&self) -> FilterMeta;}
trait Cacheable { fn config_hash(&self) -> CacheKey;}
// Filter = Compute + Describable + Cacheable (default blanket impl)trait Filter: Compute + Describable + Cacheable {}impl<T: Compute + Describable + Cacheable> Filter for T {}Impact: Medium. Requires changing trait bounds everywhere but improves extensibility.
Status: Deferred to next iteration. Current monolithic trait works for MVP.
1.2 SomaError Is Too Broad
Section titled “1.2 SomaError Is Too Broad”Current: One error enum for all crates with a catch-all Other(String).
pub enum SomaError { RequiresLabels, Cache(String), // vague Compilation(String), // vague Execution { .. }, Pruned { .. }, // control flow as error! Other(String), // catch-all ...}Problems:
Other(String)used in 12+ places as a dumping groundPrunedis control flow disguised as an error- No error context chain
Proposed Fix:
// Control flow separated from errorsenum TrialOutcome { Completed(Vec<MetricRecord>), Pruned { step: usize, reason: String },}
// Per-concern errorsenum CacheError { NotFound(CacheKey), Corrupt(String), Io(io::Error) }enum CompileError { CycleDetected, SchemaMismatch { .. }, NodeNotFound(String) }enum RuntimeError { FilterFailed { node_id: String, source: Box<dyn Error> }, ... }Impact: High. Touches every error site. Should be done before 1.0.
Status: Half done. TrialOutcome exists (soma-runtime/src/executors/study.rs) and separates completion from pruning, and soma-llm and soma-worker carry their own error types. SomaError::Pruned still exists alongside it, so the smell is narrowed, not removed.
1.3 Stringly-Typed Node IDs
Section titled “1.3 Stringly-Typed Node IDs”Current: pub type NodeId = String; used everywhere.
Problem: No compile-time guarantee that a node ID exists in the graph. Typos cause runtime errors.
Proposed Fix:
#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]pub struct NodeId(String);
impl NodeId { pub fn new(id: impl Into<String>) -> Self { Self(id.into()) } pub fn as_str(&self) -> &str { &self.0 }}Impact: Low-medium. Mostly mechanical replacement. Improves safety.
Status: Deferred. String-based IDs work for current scale.
Priority 2: Scalability Issues
Section titled “Priority 2: Scalability Issues”2.1 Graph Uses Linear Scans
Section titled “2.1 Graph Uses Linear Scans”Current: predecessors() and successors() iterate all edges: O(edges) per call.
Problem: For graphs with 10k+ nodes, this is quadratic in the compiler.
Proposed Fix: Maintain adjacency lists.
pub struct Graph { nodes: Vec<Node>, edges: Vec<Edge>, // Precomputed indices preds: HashMap<String, Vec<String>>, succs: HashMap<String, Vec<String>>,}Status: Deferred. Current graphs are small (<100 nodes).
2.2 MemoryCache Has No Eviction
Section titled “2.2 MemoryCache Has No Eviction”Current: max_bytes field exists but is never enforced (#[expect(dead_code)]).
Problem: Cache grows unbounded. Long-running studies will OOM.
Proposed Fix: Implement LRU eviction.
Status: Done. soma-runtime/src/cache/memory.rs enforces max_bytes with evict_until_fits, evicting least-recently-accessed entries.
2.3 GridSampler Builds Full Cartesian Product
Section titled “2.3 GridSampler Builds Full Cartesian Product”Current: On first call, builds all combinations in memory.
Problem: 10 dimensions with 10 points each = 10 billion combinations.
Proposed Fix: Lazy index-based generation.
fn sample(&self, space: &SearchSpace, trial_index: usize) -> Option<Params> { // Convert trial_index to multi-dimensional index // without building the full grid}Status: Done. GridSampler generates combinations from the trial index; it never materializes the product.
2.4 Serial Trial Execution
Section titled “2.4 Serial Trial Execution”Current: StudyRunner runs trials one at a time in a while loop.
Problem: Can’t leverage multiple workers or CPU cores.
Proposed Fix: Async trial execution with worker pool.
Status: Deferred to Phase 3 (agents and workers).
Priority 3: Design Smells
Section titled “Priority 3: Design Smells”3.1 soma-core Does Too Much
Section titled “3.1 soma-core Does Too Much”Current: soma-core owns 7 unrelated domains (graph, filter, cache, value, study, search, event).
Problem: Can’t import Filter without pulling in Study, Event, SearchSpace.
Proposed Fix: Eventually split into:
soma-types(Value, Schema, Error)soma-graph(Graph, Node, Edge)soma-filter(Filter trait, FilterMeta)soma-study(Study, Trial, SearchSpace)soma-event(Event, EventBus)
Status: Deferred. Single crate is simpler for now. Split when compile times become an issue.
3.2 Pipeline Has Too Many Responsibilities
Section titled “3.2 Pipeline Has Too Many Responsibilities”Current: Pipeline manages filter composition, state storage, caching, events, and fit status.
Problem: Adding features (streaming, distribution) will bloat the struct.
Proposed Fix: Separate concerns:
FilterChain— composition onlyFittedPipeline— holds trained states- Pipeline wraps both with caching and events
Status: Moot. Pipeline was removed — Graph is the only user-facing API, and the responsibilities listed here now sit in GraphSession, NodeCatalog and the cache.
3.3 ExecutionPlan Variants Are Public
Section titled “3.3 ExecutionPlan Variants Are Public”Current: All plan variants (Sequence, Parallel, Execute, Cached, Remote, Loop, Branch, Empty) are public.
Problem: Runtime consumers must exhaustively match, making extension breaking.
Proposed Fix: Keep enum public but add #[non_exhaustive] attribute.
Status: Done, and deliberately not everywhere: ExecutionPlan, Effect, Value and Event are #[non_exhaustive]; NodeOutcome and Transition are not, because every consumer must decide over them and a wildcard arm there is a silent wrong answer.
Priority 4: Missing Abstractions
Section titled “Priority 4: Missing Abstractions”| Abstraction | Purpose | Status |
|---|---|---|
DataFlow trait | Abstract input resolution from graph topology | GraphInfo resolves by predecessors, not by “last executed” |
CachePolicy | LRU, TTL, size-based eviction | LRU with max_bytes (memory), plus the tiered store’s soma cache gc/pin |
StreamingFilter | Chunk processing with stream modes | Done. StreamRun composes run_node’s primitives, locally and on the worker |
Scheduler | Distribute trials/plans across workers | Done. soma-compiler/src/scheduler.rs produces a DistributionPlan |
MetricsCollector | Pluggable observability backends | EventBus, plus the run-dir readers behind soma.runs() and soma report. A Python-implementable EventSink is still open |
DataSchema | Type-safe input/output validation | Done. Schema (dtype + shape) is checked between connected nodes at compile() |
Test Coverage Gaps
Section titled “Test Coverage Gaps”Currently Tested vs Missing
Section titled “Currently Tested vs Missing”| Category | Tested | Missing |
|---|---|---|
| Pipeline | Linear sequential | Nested, branching, conditional, empty, dependent state |
| Caching | Basic put/get/exists | Invalidation on data change, cross-run, concurrent, tiered promotion |
| Optimization | Grid, Random, basic objectives | Hyperband, Bayesian, pruning, resumption, multi-objective |
| Error handling | Predict-before-fit, missing filter | Filter panic, type mismatch, corrupt cache, invalid config |
| Concurrency | None | Shared cache, parallel events, interleaving |
| ML edge cases | None | Empty/single sample, NaN/Inf, high-dimensional |
| Integration | Individual components | End-to-end workflows, study+pipeline, cache warm-up |
Highest Priority Missing Tests
Section titled “Highest Priority Missing Tests”- Full end-to-end workflow: define filters → build pipeline → fit → predict → cache hit on re-run
- Cache invalidation: same pipeline, different data → different results
- Study + Pipeline integration: study samples params → pipeline executes with those params
- Multi-objective optimization: two objectives with different directions
- Error resilience: filter that fails mid-execution, study continues with remaining trials
- Empty/single sample: boundary conditions in real ML workflows
Improvement Roadmap
Section titled “Improvement Roadmap”Before 1.0
Section titled “Before 1.0”Apply— done, and deliberately not to the control-flow enums#[non_exhaustive]to public enumsImplement LRU eviction in MemoryCache— doneFix GridSampler for large spaces (lazy generation)— doneSeparate—PrunedfromSomaErrorintoTrialOutcomeTrialOutcomeexists; theSomaError::Prunedvariant still does tooAdd integration tests for full workflows— done- Add
#[must_use]annotations where appropriate — open
What actually blocks a 1.0, as of 2026-08-05, is none of the above: it is that publishing has never been verified end to end. See Roadmap.
Next Iteration
Section titled “Next Iteration”- Split Filter trait into Compute + Describable + Cacheable
- Per-crate error types
- Typed NodeId (newtype over String)
- Graph adjacency list precomputation
- Async trial execution in StudyRunner
Future
Section titled “Future”- Split soma-core into focused crates
- Pluggable hash algorithm for CacheKey
- Streaming execution with checkpoint support
- Worker scheduler and task queue