Skip to content

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.

907 tests (582 Rust + 325 Python), all passing, clippy clean.
soma-core Foundation types, tracking schema, graph rendering
soma-macros #[derive(SomaFilter)] proc macro
soma-compiler Graph → ExecutionPlan compiler, scheduler
soma-runtime Executor, caches, samplers, StudyRunner, run tracking
soma-coordinator Worker registry, routing, heartbeat monitoring
soma-worker Worker protocol and daemon
soma-memory KnowledgeBase (+ ChronosVector)
soma-agent Research agent loop
soma-mcp MCP server
soma-python PyO3 bindings
soma Facade crate re-exporting the workspace

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.


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 ground
  • Pruned is control flow disguised as an error
  • No error context chain

Proposed Fix:

// Control flow separated from errors
enum TrialOutcome {
Completed(Vec<MetricRecord>),
Pruned { step: usize, reason: String },
}
// Per-concern errors
enum 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.


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.


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).


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.


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).


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 only
  • FittedPipeline — 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.


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.


AbstractionPurposeStatus
DataFlow traitAbstract input resolution from graph topologyGraphInfo resolves by predecessors, not by “last executed”
CachePolicyLRU, TTL, size-based evictionLRU with max_bytes (memory), plus the tiered store’s soma cache gc/pin
StreamingFilterChunk processing with stream modesDone. StreamRun composes run_node’s primitives, locally and on the worker
SchedulerDistribute trials/plans across workersDone. soma-compiler/src/scheduler.rs produces a DistributionPlan
MetricsCollectorPluggable observability backendsEventBus, plus the run-dir readers behind soma.runs() and soma report. A Python-implementable EventSink is still open
DataSchemaType-safe input/output validationDone. Schema (dtype + shape) is checked between connected nodes at compile()

CategoryTestedMissing
PipelineLinear sequentialNested, branching, conditional, empty, dependent state
CachingBasic put/get/existsInvalidation on data change, cross-run, concurrent, tiered promotion
OptimizationGrid, Random, basic objectivesHyperband, Bayesian, pruning, resumption, multi-objective
Error handlingPredict-before-fit, missing filterFilter panic, type mismatch, corrupt cache, invalid config
ConcurrencyNoneShared cache, parallel events, interleaving
ML edge casesNoneEmpty/single sample, NaN/Inf, high-dimensional
IntegrationIndividual componentsEnd-to-end workflows, study+pipeline, cache warm-up
  1. Full end-to-end workflow: define filters → build pipeline → fit → predict → cache hit on re-run
  2. Cache invalidation: same pipeline, different data → different results
  3. Study + Pipeline integration: study samples params → pipeline executes with those params
  4. Multi-objective optimization: two objectives with different directions
  5. Error resilience: filter that fails mid-execution, study continues with remaining trials
  6. Empty/single sample: boundary conditions in real ML workflows

  1. Apply #[non_exhaustive] to public enums — done, and deliberately not to the control-flow enums
  2. Implement LRU eviction in MemoryCache — done
  3. Fix GridSampler for large spaces (lazy generation) — done
  4. Separate Pruned from SomaError into TrialOutcomeTrialOutcome exists; the SomaError::Pruned variant still does too
  5. Add integration tests for full workflows — done
  6. 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.

  1. Split Filter trait into Compute + Describable + Cacheable
  2. Per-crate error types
  3. Typed NodeId (newtype over String)
  4. Graph adjacency list precomputation
  5. Async trial execution in StudyRunner
  1. Split soma-core into focused crates
  2. Pluggable hash algorithm for CacheKey
  3. Streaming execution with checkpoint support
  4. Worker scheduler and task queue