Acorn Core

The foundation of Lattice. Defines the content-addressable storage protocol, content identifiers, composite chaining, and the LFU eviction cache.

.package(url: "https://github.com/treehauslabs/Acorn.git", from: "1.0.0")

AcornCASWorker Protocol

The core protocol that all storage workers implement. Requires actor conformance for thread safety.

public protocol AcornCASWorker: Actor {
    var timeout: Duration? { get }
    var near: (any AcornCASWorker)? { get set }
    var far: (any AcornCASWorker)? { get set }

    func has(cid: ContentIdentifier) async -> Bool
    func getLocal(cid: ContentIdentifier) async -> Data?
    func storeLocal(cid: ContentIdentifier, data: Data) async
}

Required Methods

MethodDescription
has(cid:)Check if this worker has content locally. Must not traverse the chain.
getLocal(cid:)Retrieve data from this worker's local storage only.
storeLocal(cid:data:)Store data in this worker's local storage only.

Protocol-Provided Methods

These are implemented via a protocol extension and handle chain traversal automatically:

MethodBehavior
get(cid:) Check near first → fall back to getLocal() → backfill toward near on success. Respects timeout.
store(cid:data:) Call storeLocal() then propagate to near via store().
link(near:far:) Set the near and far references in one call.
Implement three, get five
You only need to implement has, getLocal, and storeLocal. The protocol extension provides get, store, and link with full chain traversal and backfilling logic.

Timeout Support

The protocol includes a helper function for timeout enforcement:

func withOptionalTimeout<T: Sendable>(
    _ duration: Duration?,
    operation: @escaping @Sendable () async -> T?
) async -> T?

If duration is nil, the operation runs without a timeout. Otherwise, a task group races the operation against a sleep, cancelling whichever loses.

ContentIdentifier

A SHA-256 content address. Two identical byte sequences always produce the same identifier, regardless of where or when they're computed.

public struct ContentIdentifier: Hashable, Sendable {
    public let rawValue: String  // 64-character hex SHA-256

    public init(for data: Data)        // compute from data
    public init(rawValue: String)    // from known hash
}

Performance

ContentIdentifier caches the first 8 bytes of the hash as an Int for O(1) dictionary lookups. The full 64-character string comparison only happens on hash collisions.

let data = Data("Hello".utf8)
let cid = ContentIdentifier(for: data)
print(cid.rawValue) // "185f8db32271fe25f561a6fc938b2e264306ec304eda518007d1764826381969"

// Deterministic: same data → same CID
let cid2 = ContentIdentifier(for: data)
assert(cid == cid2) // always true

CompositeCASWorker

An actor that chains multiple workers into a single storage hierarchy. The initializer automatically links workers by setting near/far references.

public actor CompositeCASWorker: AcornCASWorker {
    public init(
        workers: [String: any AcornCASWorker],
        order: [String],
        timeout: Duration? = nil
    ) async

    public subscript(name: String) -> (any AcornCASWorker)?
}

Parameters

workers [String: any AcornCASWorker] Named workers. Keys are used for subscript access.
order [String] Near-to-far ordering. First element is fastest (e.g., memory), last is slowest (e.g., network).
timeout Duration? Optional global timeout for the entire chain. Per-call timeouts use the shorter of this and the call timeout.

Chain Linking

Given order: ["memory", "disk", "network"], the initializer sets:

All get() and store() calls delegate to the farthest worker, which recursively walks toward near.

Subscript Access

let disk = chain["disk"] as? DiskCASWorker
let metrics = await disk?.metrics

LFUDecayCache

A Least-Frequently-Used cache with exponential time decay. Used internally by both MemoryCASWorker and DiskCASWorker for eviction decisions.

public struct LFUDecayCache: Sendable {
    public init(
        capacity: Int,
        halfLife: Duration = .seconds(300),
        sampleSize: Int = 5
    )
}

How It Works

  1. Access scoring: Each recordAccess(cid) increments the CID's score, adjusted by a global decay multiplier
  2. Exponential decay: Scores decay with the configured half-life. A score from 5 minutes ago is worth half as much as a score from now (with default 300s half-life)
  3. Sampled eviction: evictionCandidate() picks N random entries and returns the one with the lowest effective score. This is O(k) instead of O(n)
  4. Global multiplier: Instead of decaying every score on every access, a single multiplier tracks aggregate decay. Individual scores are divided by this multiplier on read
  5. Renormalization: When the multiplier risks float underflow (~1e100), scores are batch-renormalized in the background

Key Methods

MethodDescription
recordAccess(_:)Increment score for a CID. Adds to tracking if new.
evictionCandidate()Return the lowest-scored CID among sampleSize random samples.
needsEviction(for:)True if cache is at capacity and CID isn't already tracked.
effectiveScore(for:)Current score accounting for time-based decay.
remove(_:)Remove a CID from tracking. O(1) via swap-remove.
claimRenormalization()If renormalization is pending, return the factor and key list.