Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 207 additions & 1 deletion crates/tinymemory-api/src/provider/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,17 @@
//! the families that looked most config-dependent turned out not to need any.

use async_trait::async_trait;
// Named through the wire crate rather than as a dependency of this one: the
// contract crate is deliberately dependency-light, and `tinymemory-bus`
// re-exports the chrono it serializes with precisely so a signature here names
// the same crate a frame decodes into.
use tinymemory_bus::chrono::{DateTime, Utc};

use crate::capabilities::Capability;
use crate::chunks::Chunk;
use crate::error::MemoryError;
use crate::provider::types::{IngestItem, IngestOutcome, SourceScope};
use crate::tree::{IngestRequest, QueryResult, SummaryForest, TreeLeaf, TreeStatus};
use crate::tree::{IngestRequest, QueryResult, SummaryForest, TreeLeaf, TreeNode, TreeStatus};
use crate::types::{NamespaceDocumentInput, NamespaceRetrievalContext, StoredMemoryDocument};

// The value types the summariser door exchanges. They are defined in
Expand Down Expand Up @@ -537,4 +542,205 @@ pub trait MemoryTree: Send + Sync {
) -> Result<Vec<RootSummary>, MemoryError> {
Err(MemoryError::unsupported(Capability::Tree))
}

// ── The runtime-tree doors ───────────────────────────────────────────
//
// The six members below are the markdown time tree addressed node by
// node — the doors under the host's `tree_summarizer_*` RPC surface, which
// reported the engine's answers verbatim and therefore needs the engine's
// exact shapes: the landing path of a buffered write, a single node as an
// `Option`, a child list, a status row, and the node/status a
// summarisation pass produced. [`Self::append`], [`Self::drill_down`],
// [`Self::seal`] and [`Self::cascade`] are the same tree at a coarser
// grain, and each one folds away a piece of the reply the RPCs carry —
// which is why migrating that surface onto them would have changed its
// wire format, and a door that changes what the host reports is not a
// door, it is a new surface.

/// Buffer raw content for the markdown time tree, answering with the path
/// it landed at.
///
/// The finer-grained sibling of [`Self::append`], which is this write with
/// both ends trimmed off: `append` defaults a missing timestamp to the
/// driver's "now" and discards the landing path. Here the timestamp is
/// **required** — the caller's reply echoes the instant it filed the
/// content under, and a timestamp resolved driver-side would disagree with
/// the one the caller reports by however long the call took to cross —
/// and the path comes back, because the reply names it.
///
/// The path is a *report*, not an invitation: it is the driver's own
/// spelling of the buffer file it wrote, inside the driver's workspace,
/// and the next seal consumes it. A caller displays it; nothing should
/// dereference it.
///
/// `metadata` rides into the buffer entry's frontmatter when present, and
/// the produced node carries it onward — the same field
/// [`IngestRequest::metadata`] documents.
///
/// # Errors
///
/// [`MemoryError::Invalid`] for a rejected namespace or content that is
/// blank after trimming, otherwise backend failures.
async fn runtime_buffer_write(
&self,
_namespace: &str,
_content: &str,
_timestamp: DateTime<Utc>,
_metadata: Option<serde_json::Value>,
) -> Result<String, MemoryError> {
Err(MemoryError::unsupported(Capability::Tree))
}

/// One time-tree node, or `None` when nothing sits at `node_id`.
///
/// Half of [`Self::drill_down`], unbundled — and the unbundling is the
/// point, twice over. `drill_down` folds "no such node" into
/// [`MemoryError::NotFound`] with its own message, which is right for
/// navigation and wrong for the caller here, which shapes its own miss and
/// treats "does the root exist yet" as a probe rather than a fault. And it
/// always pays for the child read, which a caller reporting one node did
/// not ask for.
///
/// # Errors
///
/// [`MemoryError::Invalid`] for a rejected namespace or a `node_id` that
/// is not `root` / `YYYY[/MM[/DD[/HH]]]`-shaped, otherwise backend
/// failures. Absence is `Ok(None)`, never an error.
async fn runtime_read_node(
&self,
_namespace: &str,
_node_id: &str,
) -> Result<Option<TreeNode>, MemoryError> {
Err(MemoryError::unsupported(Capability::Tree))
}

/// The direct children of one time-tree node, in the store's own order.
///
/// The other half of [`Self::drill_down`], on the same terms as
/// [`Self::runtime_read_node`]. A parent that does not exist has no
/// children: an empty vector, not an error — the same answer an hour leaf
/// gives, because a leaf has nothing under it by construction.
///
/// # Errors
///
/// [`MemoryError::Invalid`] on the same terms as
/// [`Self::runtime_read_node`], otherwise backend failures.
async fn runtime_read_children(
&self,
_namespace: &str,
_parent_id: &str,
) -> Result<Vec<TreeNode>, MemoryError> {
Err(MemoryError::unsupported(Capability::Tree))
}

/// One namespace's time-tree shape: node count, depth, and coverage.
///
/// The read [`Self::seal`] and [`Self::cascade`] already answer with —
/// exposed on its own so a status panel can poll it without running the
/// pass it describes. A namespace with no tree yet is the all-empty
/// status, not an error: zero nodes, zero depth, every timestamp `None`.
///
/// # Errors
///
/// [`MemoryError::Invalid`] for a rejected namespace, otherwise backend
/// failures.
async fn runtime_tree_status(&self, _namespace: &str) -> Result<TreeStatus, MemoryError> {
Err(MemoryError::unsupported(Capability::Tree))
}

/// Drain the namespace's buffer into hour leaves and propagate upward,
/// answering with the last hour node written.
///
/// [`Self::seal`] runs the same pass and answers with tree *state*; this
/// answers with the *work* — the node the pass produced, which is what the
/// triggering surface reports ("node `2024/03/15/09`, 340 tokens") and
/// what a status row cannot be unfolded into. `Ok(None)` is a pass that
/// found nothing buffered, which is a successful no-op on the same terms
/// as `seal`'s idempotence.
///
/// The fold runs on the driver's own chat provider, built the way every
/// scheduled seal builds it — see [`Self::summarise`] for why the provider
/// cannot be the caller's, and note the consequence is the same here: the
/// spend happens driver-side, under the driver's configuration.
///
/// `timestamp` is the instant the pass files freshly-drained content
/// under, supplied by the caller for the reason
/// [`Self::runtime_buffer_write`] gives.
///
/// # Errors
///
/// [`MemoryError::Invalid`] for a rejected namespace — checked before the
/// provider is built, so a bad namespace answers the same with or without
/// one configured. A driver whose summarisation provider cannot be
/// resolved fails even when the buffer is empty: the caller offered a
/// "run now" control, and "nothing to do" from a runner that could not
/// have run is the lie a disabled control exists to avoid. Otherwise
/// backend failures, which include the provider call itself.
async fn runtime_summarize(
&self,
_namespace: &str,
_timestamp: DateTime<Utc>,
) -> Result<Option<TreeNode>, MemoryError> {
Err(MemoryError::unsupported(Capability::Tree))
}

/// Rebuild the whole time tree above the hour leaves, answering with the
/// resulting status.
///
/// [`Self::cascade`] with one behavioural difference, preserved on
/// purpose: `cascade` short-circuits an empty tree without touching the
/// provider, because a scheduler calls it unconditionally. This member is
/// a person's explicit "rebuild now", and it resolves the provider
/// *first* — on the terms [`Self::runtime_summarize`] gives — so a setup
/// that could never rebuild says so instead of reporting an empty rebuild
/// that ran nothing.
///
/// # Errors
///
/// As [`Self::runtime_summarize`].
async fn runtime_rebuild(&self, _namespace: &str) -> Result<TreeStatus, MemoryError> {
Err(MemoryError::unsupported(Capability::Tree))
}

/// The compiled flavoured-root profile for one tree scope, front-matter
/// included — or `None` while nothing has been distilled for it.
///
/// The read behind a persona tool: flavoured trees are sealed under a
/// standing ask (see [`SummaryContext::ask`]), and their root compiles to
/// a small fixed-path markdown artifact. This member collapses the whole
/// lookup the host used to run against the engine directly — try the
/// compiled artifact, fall back to the tree, recompile its root — behind
/// one scope-shaped question.
///
/// # The split with the caller
///
/// The driver owns the lookup and the built/not-built verdict; the caller
/// owns the vocabulary and the presentation. Scope strings like
/// `persona/communication` are the caller's naming scheme — the driver
/// matches them literally and validates nothing about their shape beyond
/// non-emptiness, so an unknown scope is indistinguishable from an unbuilt
/// one, deliberately: both answer `Ok(None)`, and only the caller knows
/// which scopes it ever writes. The returned markdown is the **full
/// compiled artifact including its front-matter**, because the front
/// matter is part of what was compiled; a caller that wants only the prose
/// strips it, and that choice stays on the caller's side of the wire.
///
/// # `None` versus empty
///
/// `Ok(None)` means *not built*: no flavoured tree for the scope, or a
/// tree whose compiled root has an empty body — a tree that exists but has
/// never sealed compiles to front-matter over nothing, and a profile with
/// no prose is not a profile. A driver must never answer `Ok(Some)` with a
/// body-less artifact, because the caller hands the body to a model and an
/// empty string reads as "this person has no communication style".
///
/// # Errors
///
/// [`MemoryError::Invalid`] for a blank scope. Otherwise backend failures
/// — the tree lookup or the compile step failing is an error, distinct
/// from `None`, because "could not read the store" reported as "not built
/// yet" tells a user to re-run an ingestion that already worked.
async fn flavour_profile(&self, _scope: &str) -> Result<Option<String>, MemoryError> {
Err(MemoryError::unsupported(Capability::Tree))
}
}
7 changes: 4 additions & 3 deletions crates/tinymemory-bus/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ Every type that crosses the TinyMemory `TinyBus` boundary, and the names of the
members that carry them.

TinyMemory ships as a loadable module so a host does not compile the engine:
`crates/tinymemory-module` exports one object with 120 members on it, built as a
`cdylib`. A host can load that binary but cannot `use` anything out of it, so
the payload vocabulary has to be published as an ordinary library. This is it.
`crates/tinymemory-module` exports one object with `METHODS.len()` members on
it, built as a `cdylib`. A host can load that binary but cannot `use` anything
out of it, so the payload vocabulary has to be published as an ordinary
library. This is it.

| module | what it holds |
| ---------------------------------------------------------------- | ---------------------------------------------- |
Expand Down
12 changes: 12 additions & 0 deletions crates/tinymemory-bus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,15 @@ pub mod wire;

pub use names::{BUS_NAME, METHODS, OBJECT_PATH};
pub use version::{is_compatible, CONTRACT_VERSION};

/// The one foreign crate in the wire vocabulary, re-exported by name.
///
/// Timestamps cross this boundary as `chrono::DateTime<Utc>` — `tree::TreeNode`
/// and its siblings have always carried them in their fields, and the
/// runtime-tree members take one as a bare argument. A crate that spells those
/// signatures has to spell *this* chrono: `tinymemory-api` is deliberately
/// dependency-light and adds no crate of its own, so it names the type through
/// here, and anything else that does the same is guaranteed the exact crate
/// this one serializes with rather than whichever `chrono = "0.4"` its own
/// lockfile happened to resolve.
pub use chrono;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
32 changes: 31 additions & 1 deletion crates/tinymemory-bus/src/names.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,29 @@ pub mod methods {
/// `RootSummaries` — every namespace's root summary, capped.
pub const ROOT_SUMMARIES: &str = "RootSummaries";

// The runtime (markdown time) tree, node by node. `Seal` and `Cascade`
// above run whole passes and answer with tree state; these are the doors
// under the host's tree-summarizer RPC surface — the buffered write that
// reports where it landed, the two structural reads and the status read
// unbundled from `DrillDown`, and the two provider-backed passes with the
// shapes the RPC replies actually carry.
/// `RuntimeBufferWrite` — buffer raw content for the time tree, answering
/// with the path it landed at.
pub const RUNTIME_BUFFER_WRITE: &str = "RuntimeBufferWrite";
/// `RuntimeReadNode` — one time-tree node, or none.
pub const RUNTIME_READ_NODE: &str = "RuntimeReadNode";
/// `RuntimeReadChildren` — a time-tree node's direct children.
pub const RUNTIME_READ_CHILDREN: &str = "RuntimeReadChildren";
/// `RuntimeTreeStatus` — one namespace's time-tree shape and coverage.
pub const RUNTIME_TREE_STATUS: &str = "RuntimeTreeStatus";
/// `RuntimeSummarize` — drain the buffer into the tree on the driver's
/// provider, answering with the last hour node it wrote.
pub const RUNTIME_SUMMARIZE: &str = "RuntimeSummarize";
/// `RuntimeRebuild` — rebuild the whole time tree from its hour leaves.
pub const RUNTIME_REBUILD: &str = "RuntimeRebuild";
/// `FlavourProfile` — the compiled flavoured-root profile for one scope.
pub const FLAVOUR_PROFILE: &str = "FlavourProfile";

// Entities, relations and the namespaced key/value store.
/// `Entities` — entities.
pub const ENTITIES: &str = "Entities";
Expand Down Expand Up @@ -341,7 +364,7 @@ pub mod methods {
/// The order matters: `tinybus`'s `Interface::members()` returns declaration
/// order, and the module compares the two sequences directly rather than as
/// sets, so a reordering is caught alongside an addition or a removal.
pub const METHODS: [&str; 131] = [
pub const METHODS: [&str; 138] = [
methods::DRIVER_ID,
methods::CAPABILITIES,
methods::HEALTH,
Expand Down Expand Up @@ -473,6 +496,13 @@ pub const METHODS: [&str; 131] = [
methods::DEGRADED_STATE,
methods::CHUNK_SCORE,
methods::SOURCE_INGEST_STATUS,
methods::RUNTIME_BUFFER_WRITE,
methods::RUNTIME_READ_NODE,
methods::RUNTIME_READ_CHILDREN,
methods::RUNTIME_TREE_STATUS,
methods::RUNTIME_SUMMARIZE,
methods::RUNTIME_REBUILD,
methods::FLAVOUR_PROFILE,
];

#[cfg(test)]
Expand Down
52 changes: 42 additions & 10 deletions crates/tinymemory-bus/src/names_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,18 @@ fn the_newest_members_are_appended_rather_than_filed_with_their_family() {
// against this table as a *sequence*, so a new member filed beside its
// family renumbers every member after it. That is invisible here and shows
// up as the wrong method being invoked on a host built against a different
// release, which is why the tail order is asserted and not just membership.
let tail = &METHODS[METHODS.len() - 3..];
assert_eq!(
tail,
[
methods::DEGRADED_STATE,
methods::CHUNK_SCORE,
methods::SOURCE_INGEST_STATUS,
]
);
// release, which is why the positions are asserted and not just
// membership.
//
// By absolute index since the runtime-tree round: this began as a
// tail-of-table assertion, which fires on every append and is then
// re-pointed at the new tail — doing the drift-witness job once, at the
// cost of re-stating it each round. The absolute slots are the released
// wire positions themselves, which is the stronger pin and the one the
// summariser-door test below already argues for.
assert_eq!(METHODS[128], methods::DEGRADED_STATE);
assert_eq!(METHODS[129], methods::CHUNK_SCORE);
assert_eq!(METHODS[130], methods::SOURCE_INGEST_STATUS);
}

#[test]
Expand All @@ -116,3 +118,33 @@ fn the_summariser_door_holds_the_wire_slots_it_was_released_in() {
assert_eq!(METHODS[126], methods::SUMMARISE);
assert_eq!(METHODS[127], methods::ROOT_SUMMARIES);
}

#[test]
fn the_runtime_tree_doors_hold_the_wire_slots_they_were_released_in() {
// The final seven doors of the engine shed: the members the host's
// tree-summarizer RPC surface and the flavour tool stand on once nothing
// in the host links the engine. Their spellings are pinned here as well as
// read off the table for the reason every earlier round gives — a typo is
// an `UnknownMethod` the first time a released module is asked, never a
// compile error on either side.
assert_eq!(methods::RUNTIME_BUFFER_WRITE, "RuntimeBufferWrite");
assert_eq!(methods::RUNTIME_READ_NODE, "RuntimeReadNode");
assert_eq!(methods::RUNTIME_READ_CHILDREN, "RuntimeReadChildren");
assert_eq!(methods::RUNTIME_TREE_STATUS, "RuntimeTreeStatus");
assert_eq!(methods::RUNTIME_SUMMARIZE, "RuntimeSummarize");
assert_eq!(methods::RUNTIME_REBUILD, "RuntimeRebuild");
assert_eq!(methods::FLAVOUR_PROFILE, "FlavourProfile");

// Their positions are pinned by absolute index, not from the tail, for the
// reason the summariser-door test above gives: member order is wire order,
// and an assertion measured from the end moves silently under the next
// append — which is exactly the edit this exists to catch.
assert_eq!(METHODS.len(), 138);
assert_eq!(METHODS[131], methods::RUNTIME_BUFFER_WRITE);
assert_eq!(METHODS[132], methods::RUNTIME_READ_NODE);
assert_eq!(METHODS[133], methods::RUNTIME_READ_CHILDREN);
assert_eq!(METHODS[134], methods::RUNTIME_TREE_STATUS);
assert_eq!(METHODS[135], methods::RUNTIME_SUMMARIZE);
assert_eq!(METHODS[136], methods::RUNTIME_REBUILD);
assert_eq!(METHODS[137], methods::FLAVOUR_PROFILE);
}
Loading