Skip to content

Library API

Both surfaces, the MCP tools and the command line verbs, are thin wrappers over one type, Memory. If you embed Lambo in a Rust program, this is the type you work with. For full signatures, run cargo doc --open.

Memory holds the in-memory graph together with its background workers, which handle write-behind flushing and canonization. Exactly one Memory writes a given session. See The single-writer lease.

Use the builder. Prefer backends, which supplies the store, embedder, and their compatibility in one value, because it is the single place backends are constructed.

let mem = Memory::builder()
.session("my-session")
.agent("agent-a")
// The process file's settings (cadence, calibration, ...) live in
// `resolved.config`; `backends` does not carry them, so pass them first.
.config(resolved.config.clone())
.backends(resolved)
.build()
.await?;

The builder accepts session, agent, store, embedder, embedding_contract, backends, match_strategy, flush_interval, scoring_weights, and config.

build().await acquires the writer lease, fails if another process holds it, runs the startup load, and starts the background workers.

The accessors session, agent, config, embedding_contract, graph, index, and store return the current state.

MethodWhat it does
deriveDerives concepts from an interaction. Async.
derive_cited_asderive with citations and contradictions: each ConceptCitations names one of the call’s concepts by its content, the existing concepts it depends on (depends_on) and the existing concepts it contradicts (against, written as Contradicts edges, newer to older). Every reference must resolve to exactly one concept of the session, or the call fails with LamboError::Reference and writes nothing. Async.
derive_cited_async_asThe same, acknowledged before the write is applied, like derive_async_as; the references are resolved before the ack and checked again when the write is applied. Async.
record_actionRecords an action as a concept plus its causal and dependency edges.
record_action_against_async_as and record_action_against_embedded_asrecord_action_async_as and record_action_embedded_as with against: existing concepts the action contradicts, resolved like a derive’s references (never created) and written as action -Contradicts-> target.
demoteDemotes a chunk.
retractRetracts a concept. Pass a dry-run flag to get an impact report without mutating. Async.
reserve and releaseTake or release an advisory soft lock.
set_root_goal and declare_synonymSet up the session.
MethodWhat it returns
recallThe context block for a query. Async.
recall_layeredOne recall over this session and its layers, merged under one budget. Async. See Layered recall.
canonical_memoriesThe session’s canonical memories.
statsFlush lag, log depth, counts, and degraded state.
eventsA live feed of background events.

recall_layered(&layers, query) reads this session and a stack of other sessions (layers, such as a shared general session) in one call. Pass the layers nearest tier first, each as LayerRef::new(&memory), optionally with .with_weight(w) (greater than 0) and .with_min_similarity(s) (0 to 1). It returns a LayeredRecall: merged hits, each naming its session, one context and the warnings. With no layers it returns exactly what recall returns.

  • The query is embedded once and the vector is passed to every layer. A layer’s embedding contract must equal this session’s.
  • Each session ranks its own hits. The lists are merged by weight / rank: this session has weight 1.0, a layer 0.5 by default, and ties go to the nearer tier. score stays each hit’s own session score, so the merged order is not sorted by score.
  • An ordinary layer hit is merged only if its vector cosine is at least the layer’s min_similarity; keyword evidence and warnings do not bypass this absolute cutoff. An anchored dependency answer still passes (below). The weight 0.5 and floor 0.50 were measured on BGE-M3 in #157. A floor you leave unset is calibrated per embedding contract at recall time (#87): under EmbeddingGemma 2 at 768 dims (Q8_0, lambo-eg2-v2) it is 0.72, and otherwise 0.50. A floor set with .with_min_similarity(s) always applies, 0.50 included, and Config::calibrated_layer_floor = false keeps 0.50.
  • top_k limits the merged hits, except that hits with a conflict, hot or reservation warning that passed the floor are kept beyond it.
  • A dependency question (“what depends on X”) is answered by graph traversal in each session where X is found, layers included, so a ruling stored in general can be asked about from a project. Each session’s dependents are merged like any other list. The warning recall: dependency question answered by graph traversal (N dependents) counts the dependents of that session shown in the merged context, with the layer’s [<name>] prefix for a layer.
  • When the same memory is in two tiers (same canonical key, or vectors at or above semantic_match_threshold), the nearer tier’s copy is kept and its label names the other copy: also 9f8e7d6c in general.
  • One max_tokens budget covers the merged context. A layer hit’s label ends with from <session>, and if its block is truncated the marker reads lambo_inspect <id> (scope <session>).
  • A layer that is closed, has a different embedding contract, or whose recall panics is left out, with the warning recall: layer <name> is unavailable; results are from <sessions>. It never fails the call. A layer whose vector search fails keeps its warning, but contributes no ordinary hits without vector evidence; anchored dependency answers can still pass. Warnings from a layer start with [<name>] .
  • Graph edges are never followed from one session into another.

EdgeType::Contradicts is a directed concept-to-concept relation: writer -> target: from the concept or action that declares the contradiction to the existing concept it names. The direction comes from the write that declares it, not from either concept’s age, and the edge is dated by that write’s about-time: its event_time if one was given, otherwise its write time. Sending the same contradiction again reinforces the edge and does not re-date it. Recall’s detector clamps a future event_time to the write time (declared_at). Its serialized and stored name is Contradicts, following the other edge types. Graph snapshots and store mutation batches can carry it; existing database schemas need no migration. Older binaries cannot decode a store or snapshot containing the new variant. Library callers with exhaustive EdgeType matches must add an arm.

A contradiction does not change either concept’s canonization status, count as dependence or promotion evidence, increase blast radius, raise either concept’s score (it adds nothing to density or the edge bonus), or protect a Resource from the GC score cut, and its writer does not join the conflict agent set. A fresh contradiction does count as a recent write in the high-risk check. Its weight does not decay.

GC does not count it as connectivity (#19(c) owner decision 1, option C): a concept whose only edges are contradictions is an orphan, and a contradiction keeps nothing reachable from the interaction chain. The one exception is a concept that contradicts a Venerable or Canonical concept: GC protects it while the target keeps that status (see Contradiction warnings below). Agents write it through against on derive and record_action (see the methods above and the MCP reference). Supersession is a separate follow-up (#20).

Recall warns when a concept is contradicted by a newer assertion (#19(c); any status since #179). The daemon’s detector, daemon::contradiction (detect, contradiction_at, insert_contradictions, ContradictionHit), fires on a concept when:

  1. another concept of its session has a Contradicts edge to it; and
  2. that edge’s declaration date (declared_at: Edge::about_time, the declaring write’s event_time, else its write time, clamped to its write time) is strictly later than both the concept’s own origin interaction’s about-time (clamped the same way) and its latest eligible dependent evidence, when it has any. The bar is the later of the two, so a citation backdated before the concept’s origin does not let an earlier contradiction warn.

Eligible evidence is #36’s dependents relation (Dependency sources into the concept and its Hierarchical children), one piece per dependence edge, dated by that edge’s declared_at. It counts once it was written at least CycleParams::contradiction_evidence_min_age ago (from Config::canonization_edge_min_age, Stage 2’s floor); a backdated event_time does not skip the floor, and a cutoff out of chrono’s range makes nothing eligible. Re-sending an existing dependence reinforces its edge and keeps its dates, so only a new dependence edge is new evidence. A dependent written by the concept’s own origin interaction never counts. Neither does evidence written by the agent that declared the contradiction, or declared by the contradicting concept itself, so a writer cannot outweigh its own contradiction. Re-sending a contradiction reinforces the edge without re-dating it.

The detector keeps one HotListPayload::Contradiction { by, writer, at } per contradicted concept on the hot list under Condition::Contradiction, which shares Condition::Conflict’s severity; the daemon inserts contradictions before conflicts each cycle, so on a full hot list a contradiction is evicted before a conflict. At most Config::hot_list_contradiction_max entries (default half of hot_list_max) are contradictions (#196): over that budget the oldest-declared contradictions are dropped first, so contradictions never evict the high-risk, drift and stale warnings from the rest of the list. A contradiction dropped by the budget or the bound carries no recall warning until it fits again. The entry is the newest qualifying contradiction, ties going to the smaller canonical key and then the smaller id of by. writer is the agent acting when the edge was first written, falling back to by’s origin agent. No DaemonEvent is published for it. Recall re-validates it at read time like every hot entry, so it lapses when newer evidence becomes eligible, or when by is collected. The entries of one detector pass share one writer timeline, rebuilt only when the graph’s epoch moves.

Recall renders recall::format::contradiction_warning on the hit and recall::format::contradicts_warning on by. What it includes depends on the hit’s status (daemon::contradiction::forces_inclusion): a Canonical or Venerable hit is force-included and pulls by in, force-included too (#19); any other hit must earn a top_k slot, and then by is added beside it, beyond top_k when it did not rank in itself (#179). A forced companion holds no slot, so the hits that earned slots never change, and a result holds at most one forced companion per contradicted hit. It carries DetailedHit::companion_of / LayeredHit::companion_of (the hits it contradicts), serialized as companion_of only when non-empty. daemon::contradiction::contradicts_either_way is the pair check (either direction) that #178’s suggestion guard uses. Both lines quote at most CONTRADICTION_EXCERPT_CHARS (80) characters of the other concept and name its short id. The detailed recall’s annotation kinds are contradiction and contradicts; a layered merge forces a Canonical or Venerable contradiction hit and its contradicts companion past top_k, never past a floor. The companion of an ordinary hit is settled after the walk: added past top_k when a hit of its session that it contradicts (DetailedHit::contradicts) reached the answer, else cut. The pulled-in concept is not itself re-validated, so it carries only the contradicts line, and a concept contradicting it is not pulled in after it. A structural (dependency-question) recall answers by traversal and renders no hot-list warnings, contradictions included.

A hit that directly cites a contradicted concept (#207) carries recall::format::rests_on_contradicted_warning, one line per such citation, at most recall::format::STALE_CITATION_LINES (3) in the cited concepts’ id order, then recall::format::rests_on_more_warning with the count of the rest. The predicate is daemon::contradiction::stale_citations(graph, node, min_age, now): the ContradictionHit that contradiction_at gives each concept node cites through a Dependency edge (daemon::contradiction::supporting_citation: a daemon::contradiction::direct_citation written by now, dropped when node itself has a Contradicts edge to the cited concept, whoever else contradicts it). The inverse question, “which concepts cite this one”, must use the same edge test. Recall runs it on every shown hit at the recall’s now with CycleParams::contradiction_evidence_min_age, so it reads the graph rather than the hot list: when the #196 contradiction budget has dropped the cited concept’s entry, the citing hit still warns while the cited concept shows no contradiction line. The lines are part of the hit’s context block and count against max_tokens, so under a tight budget a later hit can fall out of the rendered context (included_in_context false), as with #179’s lines; hits, scores, order and top_k are unchanged. It is a warning only: nothing is forced, added or reordered, and a result with no stale citation is byte-identical to one without the check. The annotation kind is rests_on_contradicted, never forced in a layered merge.

A recall under [selection] skip_redundant (#181, recall::redundancy) skips a member redundant with a hit already taken, within the first recall::redundancy::window(top_k) (min(20, 4 × top_k)) ordinary members, and the next distinct member takes its slot. The signals, cheapest first, are RedundancySignal::SameContent (recall::redundancy::normalized_content equal: NFC, invisible codepoints stripped, whitespace collapsed, case kept), the only one by default, then, under RecallSelection::similar_signals (off by default), SemanticEdge and Cosine (both members carry a vector of the session’s contract and their cosine is at or above its effective merge threshold). A pair is never redundant when recall::redundancy::never_redundant (a Contradicts or SuspectedSupersedes edge either way) or recall::redundancy::distinct_claims holds: different recall::redundancy::claim_identifiers (snake_case, CamelCase, all-caps words, paths, versions and file names, seven or more hex digits; case kept) or digit runs of any script, a different negation_count (negators, n't with any apostrophe, avoid, unless, non-), a word against its opposite (a short antonym list, and un/dis/non/in/im prefixes), or the content words both texts share in another order. It includes, and is stricter than, the write path’s graph::canonical::same_identifiers, which is unchanged. A forced member is never skipped, nor is one that would carry a warning line of its own. The kept hit carries DetailedHit::skipped_redundant / LayeredHit::skipped_redundant (RedundantSkip { node_id, signal }, in skip order), serialized as skipped_redundant only when non-empty, and its label names them (also <short id>, at most recall::redundancy::ALSO_MENTIONS (3), then +N more), inside the block, so they count against max_tokens. With the setting off, or nothing redundant in the window, the result is byte-identical. A layered merge applies it inside each session’s recall and keeps each hit’s also items after from <session>; a farther tier’s copy merged into a nearer hit keeps what its own session skipped for it (LayerCopy::skipped_redundant), named also <short id> in <session> after the copy.

A suspected supersession (#198) is an EdgeType::SuspectedSupersedes edge, newer -> older, written by the hybrid write path (graph::suspected::record_refused_hits, at commit under the write lock) when a vector hit at or above the merge threshold was refused by the identifier guard, both concepts are the same Logic, Constraint or Observation type with the same recorded repository scope (which filters only once a scope is recorded on these types; today graph::suspected::repo_scope is None for all three, so it never excludes) and no against/depends_on between them, and graph::suspected::one_span_value_slot(older, newer) holds (one differing token span, after the first graph::suspected::PREDICATE_CUES cue in both). The direction is graph::suspected::dated_at (the origin interaction’s about-time clamped to its write time); a tie writes nothing. The edge is informational: no density, edge bonus, blast radius, dependents, recall expansion, decay or GC connectivity. It is removed when either concept is collected and by graph::suspected::settle, which against and depends_on writes call. #207 never follows it. #181’s redundancy-aware selection treats a pair as never redundant (recall::redundancy::never_redundant checks graph::suspected::is_suspected_pair(graph, a, b)), and never skips either end of a suspected pair of its session, so the older hit’s line keeps its replacement shown. Recall sets DetailedHit::possibly_superseded_by on the older hit when both are shown (one per hit, the newest replacement), and adds the recall::format::possibly_superseded_warning line with annotation kind possibly_superseded when recall::suspected::shows_line holds for the session’s contract and [embedder] suspected_supersession_line. Nothing is forced, and a result with no shown pair is byte-identical. graph::suspected::scan(graph, cosine) lists the pairs an existing session holds (lambo canon suspected).

Replacement candidates (#198 part c) are DeriveOutcome::replacement_candidates: for each concept a hybrid derive created, graph::suspected::replacement::select over its vector hits at or above the write’s merge threshold, keeping the concepts graph::suspected::replacement::eligible_pair accepts (same session, type and recorded repository scope (today it never excludes, as for the trigger), an eligible type, not an image, no against/depends_on either way), never one the call wrote; the merge tier the concept merged with is listed with merged set (every tied member), and a refused merge lists nothing; ranked by cosine then id and capped at REPLACEMENT_CANDIDATE_LIMIT (3). Each ReplacementCandidate has new, id, excerpt, cosine, merged and newer (the existing concept is dated after the new one by graph::suspected::dated_at, strictly: a tie is not newer; it may replace the new concept, so it is not one to cite in against). It is informational: nothing is written for it. A sync derive leaves it empty. The write pipeline puts it on AppliedSummary::replacement (ReplacementReport::for_receipt, None when empty, rows bounded at REPLACEMENT_RECEIPT_BYTES of serialized JSON), and ReceiptAnswer::AppliedAfterRestart gains a third field carrying it for a replay this process ran.

Garbage collection does not count a Contradicts edge as connectivity (EdgeType::counts_toward_gc_connectivity). A concept with a Contradicts edge to a concept that is Venerable or Canonical when a sweep starts is a protected class for that sweep, as a Venerable concept is: it is not collected as an orphan, as a disconnected component or by the score cut, and it is outside the collection cap’s base. The edge counts once it has been written by the sweep’s clock. Protection is not the same as a current warning: GC checks only the target’s promoted status and that the contradiction exists, not whether it is newer than the target’s evidence, so a concept stays protected after its warning has lapsed because newer evidence arrived. The protection does not extend to its neighbours, and it lapses on the first sweep after the target is demoted. Because any agent may write against, the protection can keep an otherwise collectable concept alive indefinitely by contradicting a promoted one; this is the owner’s decision (#19(c) decision 1C), noted here as a store-growth consideration.

Off by default (Config::canonization_supersede, or the top-level canonization_supersede key in lambo.toml). When it is on, each canonization cycle first demotes a Canonical concept C (Canonical -> None) when a concept S with a S -Contradicts-> C edge of the same session has earned dependent evidence, after C’s watermark, that clears Stage 2’s bars: at least three origin interactions other than S’s own, covering at least 0.3 of min(session extent, the coverage cap).

  • Evidence is #19(c)‘s: one dependence edge (a Dependency source or a Hierarchical child, a concept of the session, not from the concept’s own origin interaction), eligible once written at least canonization_edge_min_age before the cycle’s clock, dated by its about-time clamped to its write time. For S, both the edge and its dependent’s origin interaction must be dated strictly after the watermark, and the filter runs before origins are counted and spread is measured.
  • The watermark is the later of C’s origin about-time and its latest eligible evidence, not counting evidence the agent that declared the contradiction certainly wrote, nor S’s own dependence on C. When the declarer, or whether a piece of C’s evidence is the declarer’s, cannot be told apart (several agents acting at one instant), the pair is not probed: an ambiguity never demotes.
  • The bar is asymmetric on purpose: S must clear Stage 2 again with newer evidence, while one new eligible piece of evidence for C (not the declarer’s) moves the watermark past S’s support.
  • Because the declarer’s own evidence never counts for C, a single writer (or agents sharing one agent_id) can never release C from a contradiction it declared; use distinct agent ids before enabling this.
  • An assertion alone (no newer support for S) never demotes anything.
  • Neither side may be inside its re-promotion cooldown: a source demoted within canonization_repromotion_cooldown cannot supersede, and a target demoted within it cannot be superseded again. Competing sources for one target rank by their newest counted evidence, then canonical key, then id.
  • The pair rule. When two concepts each qualify against the other, only the one with the newest counted evidence (then key, then id) may supersede or hold the other. It is applied before anything commits, and the apply step judges cooldowns as of the start of the cycle, so the order targets are applied in changes nothing.
  • C is stamped last_demotion_time = now and its CanonizationEvent carries reason: Some(DemotionReason::Superseded { by: S }). S is not promoted by it. A supersession counts toward Solo’s revert penalty like any demotion.
  • The hold. Stage 3 does not promote a Venerable that a contradicting concept would supersede at once, under the same rules, whether or not it was ever Canonical. It waits at Venerable until it earns newer evidence. explain_admission and Measurements::take_hold report it as a failing held check (held: contradicted by <id> with newer evidence; Measurements::held_by).
  • Order within a cycle: supersessions commit first, then promotions against the recomputed budget (so a freed slot is spent in the same cycle), then budget demotions. At a full budget Stage 3 is gathered only when supersession probes are pending, and its store reads are skipped (the window is not evaluated and its ring does not move) when nothing was superseded. EvalOutcome::supersessions holds them, and EvalOutcome::transitions() and the durable audit follow that order. A cycle’s audit ids are time-ordered with a commit-order sequence, so a reload, which orders one instant’s rows by id, keeps commit order.
  • The pair rule covers two concepts that contradict each other. Longer cycles have soft spots it does not settle: in a 3-cycle (A against B, B against C, C against A) each member can hold the next at Venerable at once, and a source that is itself held at Venerable can still hold another Venerable. Neither deadlocks permanently: any member that earns evidence newer than its contradictor’s is released, and a Canonical member is superseded rather than held. There is no n-cycle rule.
  • At most canonization_eval_batch_size targets and canon::supersession::MAX_SUPERSESSION_PROBES (256) forward probes per cycle for supersession, and as many for the hold (a cycle at a cap logs CanonizationSupersessionProbeCap). When the hold’s cap cannot cover Stage 3’s window, the Venerables it leaves unprobed are not promoted that cycle and the window is evaluated again, the next cycle’s probes starting where the last stopped; after MAX_HOLD_DEFERRALS (3) such cycles in a row the ring moves on anyway. A store without dependent_evidence defers nothing. Each node’s evidence is read from the store once per cycle, and every verdict is re-checked against the graph under the write lock before it commits.

The store surface is one method, GraphStore::dependent_evidence, which returns a node’s rows unfiltered as a DependentEvidence; its latest_structural_evidence_at, interaction_span_since and newest_counted_at apply the rules, so every adapter agrees. It defaults to StoreError::Capability; the built-in stores implement it, and a store that does not skips supersession and the hold for the cycle with one CanonizationSupersessionUnsupported warning while the rest of the cycle runs.

CanonizationEvent::reason is demotion-only. A demotion without one (every row written before 0.6.0, and every budget demotion, which still writes none) reads as DemotionReason::Budget through CanonizationEvent::demotion_reason(); a promotion has no reason. Each store keeps it in two nullable columns, reason and superseded_by. A stored pair this build cannot read (unknown text, a malformed pair, a reason on a promotion) loads as DemotionReason::Unknown with the raw pair and a CanonizationReasonUnreadable warning, never as Budget, and the session still loads; every write gate refuses it, so a snapshot that holds a loaded Unknown reason cannot be persisted to another store (a seed, a split import, a flush of its transition) until that row is fixed. Recall marks a hit whose latest demotion was a supersession, while it is not Canonical again, with the annotation kind superseded and the line recall::format::superseded_warning; it never forces a hit into a result. Inspect ends its canonization section with the latest demotion and its reason. lambo canon dry-run --supersede runs a dry run with the setting on.

Config holds the product settings: recall defaults, flush timing, canonization thresholds, and scoring weights. Pass it to the builder with .config(...).

These are library settings you pass in Rust when you build a Memory. Thirteen of them can also come from the process file. promotion_policy is a top-level lambo.toml key with a LAMBO_PROMOTION_POLICY override, so a lambo serve process can select a policy without a code change; canonization_supersede is a top-level boolean; [daemon] carries the cadence settings: canonization_eval_interval (spelled canonization_eval_interval_secs there), gc_interval, and GC’s time bound, gc_max_interval (spelled gc_max_interval_secs) and gc_idle_floor; [embedder] carries accept_client_vectors (with a LAMBO_ACCEPT_CLIENT_VECTORS override), calibration, calibrated_merge_threshold and calibrated_layer_floor (#87), and suspected_supersession_line (#198). [fusion] carries recall_fusion (#173). [selection] carries recall_selection (#181). Everything else is code-only, and the file refuses it along with any key it does not know. No canonization threshold is settable from the file under either policy. See Configuration.

let mem = Memory::builder()
.session("my-session")
.agent("agent-a")
.config(Config { default_top_k: 10, ..resolved.config.clone() })
.backends(resolved)
.build()
.await?;
KeyDefaultWhat it controls
default_top_k5Hits a recall returns when the caller omits top_k.
default_max_tokens500Token budget for the rendered context block.
default_traversal_depth2Graph expansion depth during recall.
match_strategyHybridCanonical or Hybrid — selects the recall matching scope, whether a derive embeds, and the call-time validation rule set (see MatchStrategy in the source authority). J3-R2R-7: Canonical adds reject_repeated_observation and the single-Hierarchical-parent rule.
semantic_match_threshold0.85Similarity above which two concepts match. Must be between 0 and 1.
accept_client_vectorsfalseWhether the surfaces (MCP and CLI) accept a vector a client computed, for an image or a query. A process setting: every session one lambo serve holds gets the same value. Memory itself does not read it. Set from [embedder] accept_client_vectors or LAMBO_ACCEPT_CLIENT_VECTORS.
calibrationBuiltinWhether recall uses the built-in per-contract recent-leg score and layer floor (#87): Builtin or Off. Set from [embedder] calibration.
calibrated_merge_thresholdtrueWhether a writer attach replaces semantic_match_threshold with its embedding contract’s measured threshold (#87); false keeps semantic_match_threshold. Set from [embedder] calibrated_merge_threshold.
calibrated_layer_floortrueWhether, in this session’s layered recall, a layer whose floor was not set takes its embedding contract’s measured floor (#87; EmbeddingGemma 2 at 768 dims: 0.72) instead of 0.50. Needs calibration at Builtin. A floor set with with_min_similarity always applies. Set from [embedder] calibrated_layer_floor.
suspected_supersession_lineValidatedWhen recall shows the ⚠ Possibly superseded by "…" (id …), not declared line on the older hit of a suspected supersession (#198): Validated only under an embedding contract whose trigger precision was measured at 0.90 or better (BGE-M3), On for every contract, Off for none. The possibly_superseded_by field is set either way. Set from [embedder] suspected_supersession_line; see Configuration.
backend_flush_interval1 secondHow often pending writes flush, which sets how far behind durable storage a session can be.
backend_flush_max_batch500Mutations per flush batch.
backend_flush_retries3Attempts before a batch is dead-lettered.
backend_log_max50000Cap on the write-behind log.
daemon_tick_interval1 secondHow often the background workers rescore and re-run the detectors.
max_canonical_nodes1000Cap on canonical concepts.
canonization_min_peer_count20Inert since 0.6.0: no policy reads it, because Stage 1 admits on evidence with no peer minimum. A non-default value is named by Config::warnings.
canonization_edge_min_age60 secondsHow long an edge must exist before it counts as evidence.
canonization_coverage_window14 daysStage 2 denominator cap: eligible evidence spread / min(session extent, cap). At least one day, in whole milliseconds, at most i64::MAX milliseconds. A library setting only; older evidence does not expire.
canonization_eval_interval60 secondsHow often promotion is evaluated. Also settable from the file, as [daemon] canonization_eval_interval_secs.
canonization_eval_batch_size50Concepts evaluated per pass.
canonization_repromotion_cooldown300 secondsWait before a demoted concept can be promoted again.
canonization_supersedefalseEvidence-driven supersession (see Supersession). Also settable from the file, as the top-level canonization_supersede key.
promotion_policySwarmWhich promotion policy canonization runs. Swarm admits a Candidate on evidence: gc_survived >= 3 and dependents from at least two interactions other than the concept’s own (no peer minimum, no percentile cut; the composite score only orders the queue), and every later hop is its stage’s verdict. Solo (single-writer promotion) admits a Candidate when (Sessions × 1.0) + (Human Confirmed × 4.0) + (Dependent Interactions × 2.0) − (Reverts × 3.0), multiplied by its concept type’s eviction resistance (Constraint 1.5 … Observation 0.7), reaches 3.0; sessions are distinct ≥24-hour-apart supports (derivations and declared dependents; since 0.6.0 not actions that only produced or modified the concept) measured on event time, dependent interactions are the same count Swarm’s Stage 1 reads, and human confirmations enter only through the confirm verb (Memory::confirm_human), never an agent write. Under Solo the Candidate → Venerable and Venerable → Canonical hops need the score’s band (6.0 / 10.0) and the stage verdict Swarm needs, cooldown included (since 0.6.0; before, the band alone decided). Swarm stays the default. Selectable from lambo.toml or LAMBO_PROMOTION_POLICY (see Configuration); there is no serve flag.
scoring0.25, 0.20, 0.20, 0.35Recency, frequency, session activity, and density weights.
recall_weights0.5, 0.5How much the background score and the query relevance each contribute.
recall_fusionmaxHow recall combines its keyword, vector and recent legs into the query relevance (#173): RecallFusion { kind, k, keyword_weight, vector_weight, recent_weight }, FusionKind::Max (the highest raw leg score) or FusionKind::Rrf (reciprocal rank fusion, k 3, weights 1, 1 and 0.3). Set from [fusion]; see Recall fusion for the measurements.
recall_selectionoffWhether recall skips a member redundant with a hit already taken (#181): RecallSelection { skip_redundant, similar_signals }, identical content only unless similar_signals (off by default) adds the Semantic-edge and cosine signals. Set from [selection]; see Redundancy-aware selection.

A newly derived concept has no daemon score until the daemon completes its next cycle. The derive wakes the daemon, but the window is the rest of any cycle already running plus the next rescore, not the rescore alone. On a fixture graph at dogfood scale (6,466 nodes, 11,600 edges, release build) a cycle takes about 9 ms (rescore about 5 ms, the four detectors about 6 ms), a garbage-collection sweep adds about 11 ms, and an isolated derive is scored within 6 ms median, 23 ms worst of 60. Under a sustained write burst the window does not close: with a derive every 2 ms some concept was unscored for the whole 3 s burst, and with one every 10 ms for 44% of it. If a recall in that window finds such a concept through the keyword or vector leg, and query relevance alone would rank it within top_k (or a live hot-list warning would force it in), the recall ranks every hit by query relevance alone, at the configured query weight. A fresh concept that could not make the result leaves the normal blend in place. The recall’s ledger line reports this as cold_start. A fresh match is then compared with older hits on one scale, and a fresh weak match stays below a stronger established one. Canonical hits remain first and live hot-list warnings are still force-included. A concept found only through the recent leg does not start this mode, and an explicit daemon score of zero counts as scored. Each query leg keeps its own scale: at the default weight a recent-only hit (0.35) scores 0.175, below a vector match at 0.75 (0.375). Under a contract with a calibrated recent score (EmbeddingGemma 2 at 768 dims: 0.63, [embedder] calibration, #87) a recent-only hit scores 0.315; a recall by vector with no text skips the recent leg. In this mode traversal and sibling members score 0, so they rank after every query hit, and a concept with an active reservation that the normal blend would have shown is kept, with its reservation warning, in addition to top_k: a cold result can therefore exceed top_k by the reservation holders it keeps. Once the daemon publishes its next score table, the normal background-plus-query blend returns, so hit scores and order can change. With w_query = 0, recall keeps using daemon scores throughout.

Config::validate() checks the settings that must fail closed rather than quietly poison selection. A scoring weight that is negative or not a number drops that one dimension to zero instead of failing the session.

close().await flushes the pending write-behind tail and releases the lease. It is time-bounded. If a dependency hangs, it stops rather than waiting forever, and it reports honestly that the un-flushed tail was not saved.

Backends are chosen by Cargo feature and configuration, not loaded as plugins. See Configuration for the feature flags.

GraphStore handles durable graph storage. The implementations are the in-memory store, the SQLite store, and the CockroachDB store. Its methods cover schema setup, flushing a batch, loading a session, keyword and vector candidate lookup, blast radius, interaction spans, the two supersession evidence accessors, and canonization records, plus the lease methods below.

A store also declares what it can do. capabilities reports its features, and vector_dimensions reports the width of its vector column, or nothing when it has none. A store that reports a width must agree with the embedder’s, and Lambo refuses to start when they differ.

Embedder turns text into vectors.

pub trait Embedder: Send + Sync {
fn dimensions(&self) -> usize;
async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedError>;
// Optional, with defaults:
async fn embed_batch(
&self,
texts: &[&str],
deadline: tokio::time::Instant,
) -> Vec<Result<Vec<f32>, EmbedError>>;
fn batch_capacity(&self) -> usize; // default 1
}

embed_batch embeds several texts in one call and returns one result per text, in order. Each result belongs to its own text: an adapter that batches must not let one text’s failure fail the others. The default calls embed once per text, and batch_capacity defaults to 1, which tells Lambo that gathering texts gains nothing. embed_batch takes a deadline: a text not embedded by then fails its own slot as unavailable, and the texts already embedded keep their vectors. batch_capacity is the most texts worth gathering into one call; a larger call is allowed and must still answer every slot. An adapter whose backend embeds several texts in one round overrides both, and Lambo then sends each write’s texts in calls of up to that many. The candle adapter does this, with a capacity of 8.

The implementations are the fixture embedder, BGE-M3 over llama-server (or any OpenAI-compatible embeddings endpoint), BGE-M3 in-process through candle, Gemini on Vertex, and EmbeddingGemma 2 over llama-server. Amazon Titan through Bedrock is planned and not yet built.

A session has exactly one writer. Lambo enforces this in the store with a per-session lease that records the current holder and a time-to-live.

  • build() acquires the lease or fails, naming the process that holds it. close() releases it.
  • A heartbeat keeps a live holder’s lease fresh and lets a crashed holder’s lease expire.
  • If a holder’s lease is taken over, that holder is fenced. Its further writes are refused and its pending writes are dropped, so it cannot overwrite the new holder.

See MCP tools and Configuration.

Stage 2 requires at least three eligible dependent origin interactions and coverage of at least 0.3. It divides their time spread by min(session extent, 14 days) by default. The session extent is its about-time extent: from the earliest to the latest of all its interactions, each at its event time, else its creation time. When that extent exceeds fourteen days, a spread of exactly 4.2 days passes the coverage bar. A session whose extent is fourteen days or less, the REST API fixture and the demo included, keeps its existing coverage. Empty support has coverage zero; nonempty support in a single-point session has coverage one. Coverage is clamped to one.

The extent counts every interaction in the session, including ones younger than the age floor and ones whose about-time is after the evaluation clock (a backdated or future event_time). Only the evidence side is age-gated. This predates the cap; the cap bounds its effect, since a far-future interaction can stretch the denominator to at most fourteen days.

This is a denominator cap, not a rolling expiry window: evidence older than fourteen days still counts. Age checks use the caller’s injected clock and each edge and interaction’s event time, falling back to creation time. The node’s own origin interaction never supports itself.

Library callers can set Config::canonization_coverage_window, for example Duration::from_secs(28 * 86_400), then call Config::validate. The duration must be at least one day (MIN_COVERAGE_WINDOW), in whole milliseconds, and fit within i64::MAX milliseconds. A shorter cap would let a burst of citations minutes apart cover the whole denominator of any longer session, which is the burst Stage 2 exists to refuse. The window-aware store boundary validates it again. EvalParams::from_config and gate_progress_with_config use the same cap. The existing gate_progress wrapper selects the default fourteen days.

GraphStore::interaction_span remains the raw, uncapped measurement. interaction_span_with_window measures the same eligible evidence against the selected cap. The built-in stores and TieredStore implement it. Third-party adapters still compile without an override, but Stage 2 returns StoreError::Capability until they implement the new accessor; it never silently substitutes raw coverage. Until then, under either policy, every concept the cycle checks against Stage 2 gets no evidence: no Candidate becomes Venerable, and no Venerable passes the re-check that precedes its Stage 3 probe, so none becomes Canonical. The rest of the cycle still runs (Stage 1 admission, the Stage 3 window and the Canonical budget’s demotions), and each such cycle logs one CanonizationStage2Unsupported warning naming the missing capability. Since 0.6.0 Solo asks for the span too, and degrades the same way. The portal’s inspect panel omits the four Swarm gates for such a store. Forward the call if your adapter wraps a built-in store. stage2_passes selects fourteen days, stage2_passes_with_window accepts a custom cap, and stage2_evidence_passes shares the origin/coverage threshold comparison.

Since 0.6.0 (#36) no stage selects by score. Under Swarm, Stage 1 admits a Candidate when gc_survived >= 3 and its dependents come from at least two interactions other than its own, each dependence edge and interaction older than canonization_edge_min_age. That count is the distinct Stage 2 measures with interaction_span_with_window, computed for every concept in one pass over the in-RAM graph (canon::dependent_interaction_counts, or canon::dependent_interactions for one node), so Stage 1 issues no store query. There is no peer minimum and no percentile cut; the composite score orders the admitted set and decides which concepts a bounded cycle takes first. Under Solo, Stage 1 is the score band, and every later hop needs its band and the same Stage 2 or Stage 3 verdict Swarm needs, cooldown included.

canon::stage1_candidates(graph, scores, min_age, now) returns the admitted set in that order. PromotionScorer::admits_hop(graph, node, to, params, now) lost its evidence argument: the evaluator only asks it about a node whose stage verdict passed, and asks it first when building the Stage 2 and Stage 3 windows, so under Solo a node whose band cannot admit the hop is never probed. canon::solo_breakdown returns the Solo score’s terms. EvalParams::min_peer_count and Config::canonization_min_peer_count are #[deprecated] and inert.

To explain a concept, read its graph-side inputs under the read guard with canon::admission_inputs(&graph, node, &params, now), then call canon::explain_admission(store, session, &inputs, &params, now) with no lock held. The Admission it returns names the policy, the current and next status, whether every gate of the next hop holds (eligible), each gate’s measurement and bar (checks), Solo’s score terms, and one reason per gate that fails; canon::render_admission renders it as the text lambo_inspect and lambo inspect print. A store without the window-aware span is explained the way the evaluator treats it: no Stage 2 evidence, said in the reasons. A Venerable is not eligible while the Canonical budget is full (the canonical_budget gate), because no Stage 3 window runs then. /api/inspect carries the same object as admission, beside gate_progress, whose four-gate shape is unchanged; a caller that needs both can take the store measurements once with canon::Measurements::take and pass them to canon::explain_measured and canon::gate_progress_measured.