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
4 changes: 4 additions & 0 deletions crates/tinymemory-api/src/null_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,10 @@ fn every_optional_method_fails_with_its_advertised_family_name() {
author: None,
channel_label: None,
platform: None,
to: Vec::new(),
cc: Vec::new(),
subject: None,
list_unsubscribe: None,
};
assert_unsupported(block_on(driver.ingest_document(ingest)), Capability::Ingest);

Expand Down
119 changes: 117 additions & 2 deletions crates/tinymemory-api/src/provider/chunks.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! The chunks family: direct read access to the stored chunk tier.
//!
//! A driver advertising [`Capability::Chunks`](crate::capabilities::Capability::Chunks)
//! A driver advertising [`Capability::Chunks`]
//! can list and fetch individual chunks, and hand back the embedding vectors it
//! holds for them.
//!
Expand Down Expand Up @@ -32,6 +32,7 @@

use async_trait::async_trait;

use crate::capabilities::Capability;
use crate::chunks::Chunk;
use crate::error::MemoryError;
use crate::provider::types::SourceScope;
Expand All @@ -40,7 +41,9 @@ use crate::provider::types::SourceScope;
// — they cross the module boundary, and a host that only makes calls must be
// able to name them without compiling this trait — and re-exported here so
// every historical path keeps resolving and the types stay the same types.
pub use tinymemory_bus::provider::chunks::{ChunkDetail, ChunkEmbedding, ChunkQuery};
pub use tinymemory_bus::provider::chunks::{
ChunkDetail, ChunkEmbedding, ChunkListRow, ChunkQuery, SourceTotal,
};

/// Direct read access to the chunk tier.
///
Expand All @@ -67,6 +70,118 @@ pub trait MemoryChunks: Send + Sync {
scope: Option<&SourceScope>,
) -> Result<Vec<Chunk>, MemoryError>;

/// How many chunks `query` matches, ignoring its `limit` and `offset`.
///
/// The predicate is [`Self::list_chunks`]'s, exactly: same filters, same
/// `scope`, same fail-closed reading of an empty allowlist. Only the page
/// bounds are dropped, because a total that moved as the caller paged
/// through it would not be a total.
///
/// # Why this is a member and not the caller's arithmetic
///
/// A caller rendering "showing 20 of 431" cannot derive 431 from a page: it
/// would have to list the whole match set unbounded, which is the query the
/// row limit exists to prevent, and it would still be capped by the
/// driver's own ceiling — silently, so 10,000 would read as the truth. The
/// count has to be answered where the `WHERE` clause is.
///
/// The two must be built from one predicate driver-side. A count that
/// disagrees with the list beside it points the caller at pages that hold
/// nothing, which is worse than not offering a count at all.
///
/// # Errors
///
/// [`MemoryError::Unsupported`] from a driver that implements this family
/// but predates this member — it is deliberately not derived from
/// [`Self::list_chunks`] by default, because that default would silently
/// answer with the driver's row cap instead of the real total. Otherwise
/// backend failures only; no match yields `0`.
async fn count_chunks(
&self,
_query: &ChunkQuery,
_scope: Option<&SourceScope>,
) -> Result<u64, MemoryError> {
Err(MemoryError::unsupported(Capability::Chunks))
}

/// The same rows [`Self::list_chunks`] returns, each carrying the stored
/// facts a listing renders beside it.
///
/// Same predicate, same `scope`, same newest-first order, same page
/// bounds — a caller can swap one for the other without re-sorting, and
/// [`Self::count_chunks`] labels either.
///
/// # Why this is not `list_chunks` plus a call per row
///
/// A browser page shows a chunk's vault path, its lifecycle state, and
/// whether it has been embedded. Assembling those from
/// [`Self::chunk_detail`] is one call per row — fifty to a thousand bus
/// round trips for one screen, and each of those trips also reads the
/// chunk's body off disk to fill a field the list will not display. That
/// is precisely the fan-out [`ChunkDetail`]'s own docs exist to argue
/// against, reintroduced one level up.
///
/// # Why the rows are not `ChunkDetail`
///
/// [`ChunkListRow`] is [`ChunkDetail`] minus its body, and the missing
/// field is the point: `ChunkDetail::body` promises that `None` means the
/// vault read *failed*, which a list can only honour by reading every
/// file or by lying. That type's docs carry the full argument.
///
/// # Errors
///
/// [`MemoryError::Unsupported`] from a driver that implements this family
/// but not this member — not defaulted to [`Self::list_chunks`] with empty
/// detail, which would report every row as unembedded and pathless.
/// [`MemoryError::Invalid`] for a [`ChunkQuery`] filter the driver cannot
/// apply, per that type's docs. Otherwise backend failures; no match
/// yields an empty vector.
async fn list_chunk_details(
&self,
query: &ChunkQuery,
scope: Option<&SourceScope>,
) -> Result<Vec<ChunkListRow>, MemoryError> {
let _ = (query, scope);
Err(MemoryError::unsupported(Capability::Chunks))
}

/// What the driver holds per logical source, newest source first.
///
/// One row per `(source_kind, source_id)` group, ordered by
/// [`SourceTotal::most_recent_ms`] descending — the same ordering
/// [`Self::list_chunks`] uses, so a browser showing sources above chunks
/// does not flip between two notions of "first". `limit` caps the rows and
/// is clamped to the driver's own ceiling, exactly as
/// [`ChunkQuery::limit`] is.
///
/// `scope` filters the chunks the groups are computed *from*, not the
/// groups afterwards: a scoped caller must not learn a forbidden source
/// exists by seeing its total, and must not see permitted sources carrying
/// counts that include rows it cannot read.
///
/// # Why this is a member and not a fold over a chunk page
///
/// A group is not a row in any table, so the only way to derive it is to
/// list every chunk in the store and group them client-side — the
/// unbounded query the page limit exists to prevent, and one that would
/// silently answer from the driver's row cap instead of the whole store.
/// It is [`Self::count_chunks`]'s argument applied to a `GROUP BY`: the
/// aggregate has to be computed where the rows are.
///
/// # Errors
///
/// [`MemoryError::Unsupported`] from a driver that implements this family
/// but not this member. Otherwise backend failures; an empty store yields
/// an empty vector.
async fn source_totals(
&self,
limit: usize,
scope: Option<&SourceScope>,
) -> Result<Vec<SourceTotal>, MemoryError> {
let _ = (limit, scope);
Err(MemoryError::unsupported(Capability::Chunks))
}

/// One chunk by id.
///
/// # Errors
Expand Down
119 changes: 118 additions & 1 deletion crates/tinymemory-api/src/provider/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use crate::capabilities::Capability;
use crate::chunks::Chunk;
use crate::error::MemoryError;
use crate::provider::types::{IngestItem, IngestOutcome, SourceScope};
use crate::tree::{IngestRequest, QueryResult, TreeStatus};
use crate::tree::{IngestRequest, QueryResult, SummaryForest, TreeLeaf, TreeStatus};
use crate::types::{NamespaceDocumentInput, NamespaceRetrievalContext, StoredMemoryDocument};

/// Bulk content ingestion — the driver owns chunking and embedding.
Expand Down Expand Up @@ -192,6 +192,21 @@ pub trait MemoryDocuments: Send + Sync {
/// implicitly on ingest because the **host** owns scheduling. A driver runs one
/// step when asked; it does not get to install its own background loop. This is
/// the same rule as the engine's `queue::run_once`.
///
/// # Navigating one node, and walking the whole forest
///
/// [`Self::drill_down`] addresses a node by id and returns it with its direct
/// children — enough to descend a tree a caller is already inside.
/// [`Self::summary_forest`] and [`Self::recent_leaves`] answer the question
/// that has no starting id: what trees exist, how they nest, and what content
/// hangs off them. Both are here rather than in
/// [`MemoryRetrieval`](crate::provider::MemoryRetrieval) because neither ranks
/// and neither takes a query; they are structure, not results.
///
/// The embedded driver happens to serve the two from different storage — the
/// markdown time tree on disk, the sealed summary forest in tables — and the
/// contract deliberately does not encode that split. See
/// [`crate::tree`] for the shapes and why they are described separately there.
#[async_trait]
pub trait MemoryTree: Send + Sync {
/// Append raw content to the ingestion buffer for later sealing.
Expand Down Expand Up @@ -246,4 +261,106 @@ pub trait MemoryTree: Send + Sync {
///
/// Backend failures only.
async fn cascade(&self, namespace: &str) -> Result<TreeStatus, MemoryError>;

/// Walk every sealed summary the store holds, across every tree.
///
/// # Why [`Self::drill_down`] cannot answer this
///
/// `drill_down` starts from a node id and returns that node with its
/// direct children. A caller that wants the whole forest has no id to
/// start from — that is what it is asking for — and no way to discover
/// one, because nothing else in the contract enumerates trees. Walking it
/// by repeated `drill_down` would also be one round trip per node, over a
/// bus, to rebuild a shape the driver already has in one table.
///
/// [`crate::provider::MemoryRetrieval::retrieve_children`] does not answer
/// it either, for a different reason: it *ranks*. It needs a seed node and
/// returns scored hits without a parent link, which is a reading list
/// rather than a graph.
///
/// # `scope` is a predicate, not a post-filter
///
/// The allowlist must be applied **inside** the driver's query for the
/// reasons in [`SourceScope`], and this member is the one where getting it
/// wrong is least visible: an unscoped forest walk hands back every source
/// in the store at once, which is precisely the shape a per-turn source
/// gate exists to prevent. `None` means unrestricted and must be a
/// decision, not a default the caller drifted into.
///
/// A driver returns nodes whose tree the scope allows. It may therefore
/// return a node whose `parent_id` names one it withheld; see
/// [`crate::tree::TreeSummary::parent_id`] for what a caller does with
/// that.
///
/// # Bounds
///
/// `limit` caps the nodes returned and the driver clamps it to its own
/// cap — a caller cannot raise the ceiling by asking for more, the same
/// rule [`crate::provider::ChunkQuery::limit`] carries. Hitting either
/// bound sets [`SummaryForest::truncated`] rather than erroring.
///
/// Tombstoned summaries are never returned. A driver that keeps them
/// filters them out here; "deleted" is not a state a caller has to know
/// about to draw a graph.
///
/// # Errors
///
/// [`MemoryError::Unsupported`] from a driver that has a tree family but
/// cannot enumerate it — deliberately not an empty forest, because a
/// driver with trees reporting none is a lie a caller would render as an
/// empty store. Backend failures otherwise; a store that has sealed
/// nothing returns an empty, untruncated forest, which is true of it.
async fn summary_forest(
&self,
_limit: usize,
_scope: Option<&SourceScope>,
) -> Result<SummaryForest, MemoryError> {
Err(MemoryError::unsupported(Capability::Tree))
}

/// The most recent leaves, each with the summary that sealed it, newest
/// first.
///
/// The forest's bottom edge. [`Self::summary_forest`] returns the summary
/// nodes and the child ids they sealed over; this returns the leaves
/// themselves with the back-pointer that says which summary claimed them,
/// so a caller can attach content to the structure without one lookup per
/// leaf.
///
/// # Why not [`crate::provider::MemoryChunks::list_chunks`]
///
/// That returns the same rows and drops the link: a [`Chunk`] does not say
/// which summary sealed it, and the link is what makes a leaf part of a
/// tree rather than a loose row. It is also the half that changes without
/// the chunk changing — a leaf gains a parent when the scheduler seals it,
/// long after ingest.
///
/// Both halves are separate calls rather than one combined read because
/// the two bounds are separate: a caller may want the whole forest
/// skeleton and only the newest few hundred leaves, and folding them into
/// one response would make the smaller bound pay for the larger.
///
/// # Bounds and scope
///
/// As [`Self::summary_forest`]: `limit` is clamped by the driver, and
/// `scope` is applied inside the query, before the limit, so a disallowed
/// source cannot starve permitted ones out of the page.
///
/// [`TreeLeaf::preview`] is a label, capped at
/// [`crate::tree::LEAF_PREVIEW_CHARS`] characters. Bodies are
/// [`crate::provider::MemoryChunks::chunk_detail`]'s job, one row at a
/// time; a forest-sized read carrying whole bodies would not fit a frame.
///
/// # Errors
///
/// [`MemoryError::Unsupported`] on the same terms as
/// [`Self::summary_forest`]. Backend failures otherwise; a store with no
/// leaves returns an empty vector.
async fn recent_leaves(
&self,
_limit: usize,
_scope: Option<&SourceScope>,
) -> Result<Vec<TreeLeaf>, MemoryError> {
Err(MemoryError::unsupported(Capability::Tree))
}
}
Loading
Loading