Skip to content

Commit 00bd376

Browse files
authored
Merge pull request #78 from tinyhumansai/graph-view-and-ingest-api
Add a graph view, document and URL intake, and a namespace convention
2 parents 1d6b997 + cc508b3 commit 00bd376

39 files changed

Lines changed: 5964 additions & 12 deletions

File tree

Cargo.lock

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ crates/
4343
│ them
4444
├── tinymemory-sources/ memory-source contracts and readers — local folders
4545
│ always, GitHub/RSS/web pages behind `network`
46+
├── tinymemory-documents/ document and URL intake: sniff a format, convert it
47+
│ to markdown, and write it into whichever engine is
48+
│ bound. The URL half is behind `network` and reuses the
49+
│ source readers' SSRF guard rather than growing a second
4650
├── tinymemory-tinycortex/ the TinyCortex engine seen through the contract
4751
├── tinymemory-remote/ native HTTP dialects for Supermemory, Mem0, and Cognee
4852
├── tinymemory-conformance/ the behavioural suite every driver must pass
@@ -79,6 +83,8 @@ composition — no storage engine, no HTTP stack, no native library.
7983
| `sync` | `tinymemory::sync` — the Composio normalisers |
8084
| `sources` | `tinymemory::sources` — source contracts and local readers |
8185
| `sources-network` | `sources`, plus the GitHub/RSS/web-page readers |
86+
| `documents` | `tinymemory::documents` — document intake and markdown conversion |
87+
| `documents-network` | `documents`, plus the URL fetch path |
8288
| `conformance` | `tinymemory::conformance` — the driver contract suite |
8389
| `memory-git` | git-backed diff snapshots (implies `tinycortex`; links libgit2) |
8490
| `contacts` | the macOS address-book seeding path (implies `core`) |

crates/tinymemory-api/src/lib.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@
7070
//! - [`traits`]: the [`traits::Memory`] storage-backend trait.
7171
//! - [`chunks`]: the persisted chunk model ([`chunks::Chunk`], [`chunks::Metadata`],
7272
//! [`chunks::SourceRef`], …) and the deterministic [`chunks::chunk_id`].
73+
//! - [`graph`]: the bounded graph-view model ([`graph::GraphView`],
74+
//! [`graph::GraphViewQuery`], [`graph::GraphNode`], [`graph::GraphEdge`]) —
75+
//! the graph counterpart of [`tree`], and what
76+
//! [`provider::MemoryGraph::graph_view`] returns.
77+
//! - [`namespace`]: the `<section>:<scope>` namespace convention
78+
//! ([`namespace::Namespace`], [`namespace::MemorySection`]) and its
79+
//! validator.
7380
//! - [`tree`]: the markdown summary-tree node model ([`tree::TreeNode`],
7481
//! [`tree::NodeLevel`], [`tree::TreeStatus`], …).
7582
//! - [`tool_memory`]: tool-scoped rule contracts ([`tool_memory::ToolMemoryRule`], …).
@@ -99,7 +106,8 @@ pub mod host;
99106
// point: a second definition would need a conversion at the module seam that
100107
// nothing type-checks.
101108
pub use tinymemory_bus::{
102-
capabilities, chunks, error, goals, health, recall, tool_memory, tree, types, version, wire,
109+
capabilities, chunks, error, goals, graph, health, namespace, recall, tool_memory, tree, types,
110+
version, wire,
103111
};
104112
/// The mandatory-family composition: wrap any [`traits::Memory`] backend as a
105113
/// complete [`provider::MemoryProvider`].

crates/tinymemory-api/src/provider/knowledge.rs

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,26 @@
1010
//! extraction models, hotness decay curves, and snapshot retention are driver
1111
//! concerns and appear in none of these signatures.
1212
13+
use std::collections::BTreeSet;
14+
1315
use async_trait::async_trait;
1416

1517
use crate::error::MemoryError;
18+
use crate::graph::{GraphEdge, GraphNode, GraphView, GraphViewQuery};
1619
use crate::provider::types::{DiffReport, EntityHit, SnapshotRef};
1720
use crate::types::{GraphRelationRecord, MemoryKvRecord};
1821

22+
/// How many edges the default [`MemoryGraph::graph_view`] traversal scans per
23+
/// predicate when it has to resolve *inbound* edges.
24+
///
25+
/// [`MemoryGraph::relations`] filters by subject and predicate but not by
26+
/// object, so inbound expansion has no indexed form in this contract and the
27+
/// default traversal falls back to a bounded scan. The bound exists so a graph
28+
/// larger than memory cannot be pulled into a view; hitting it sets
29+
/// [`GraphView::truncated`]. A driver whose store indexes the object column
30+
/// should override `graph_view` and skip this path entirely.
31+
pub const INBOUND_SCAN_LIMIT: usize = 4_096;
32+
1933
/// The entity index: who and what the stored memory is about.
2034
#[async_trait]
2135
pub trait MemoryEntities: Send + Sync {
@@ -140,6 +154,238 @@ pub trait MemoryGraph: Send + Sync {
140154
/// [`MemoryError::Invalid`] for a malformed edge, otherwise backend
141155
/// failures.
142156
async fn put_relation(&self, relation: GraphRelationRecord) -> Result<(), MemoryError>;
157+
158+
/// Assemble a bounded, renderable slice of the graph.
159+
///
160+
/// This is to [`Self::relations`] what
161+
/// [`crate::provider::MemoryTree::drill_down`] is to a raw node read: one
162+
/// call returns a node *together with its surroundings*, already joined
163+
/// into a node set and an edge set, so navigating a graph is a sequence of
164+
/// view calls rather than a client-side reassembly that every caller would
165+
/// write differently.
166+
///
167+
/// # The default implementation
168+
///
169+
/// Provided, not required: it breadth-first expands
170+
/// [`GraphViewQuery::seeds`] using [`Self::relations`] alone, so every
171+
/// existing driver gains a graph view without writing one, and a driver
172+
/// that advertises no [`crate::capabilities::Capability::Graph`] family
173+
/// still surfaces the same [`MemoryError::Unsupported`] its `relations`
174+
/// returns.
175+
///
176+
/// It costs one `relations` call per node visited, including one final
177+
/// round at the outermost hop that adds no nodes and exists only to close
178+
/// edges *between* nodes already in the view — without it the outer ring
179+
/// renders as a star rather than as the graph it is. A driver with a native
180+
/// multi-hop traversal should override this and use it.
181+
///
182+
/// Inbound expansion has no indexed form here — `relations` cannot filter
183+
/// by object — so [`crate::graph::GraphDirection::In`] and
184+
/// [`crate::graph::GraphDirection::Both`] fall back to a scan capped at
185+
/// [`INBOUND_SCAN_LIMIT`] per predicate.
186+
///
187+
/// # Errors
188+
///
189+
/// Whatever [`Self::relations`] returns. Bounds are never an error: a
190+
/// traversal that hits one returns the partial view with
191+
/// [`GraphView::truncated`] set.
192+
async fn graph_view(&self, query: &GraphViewQuery) -> Result<GraphView, MemoryError> {
193+
let namespace = query.namespace.as_deref();
194+
let mut view = GraphView {
195+
namespace: query.namespace.clone(),
196+
seeds: query.seeds.clone(),
197+
..GraphView::default()
198+
};
199+
200+
// Each predicate needs its own call: `relations` takes one, not a set.
201+
// An empty filter becomes the single unfiltered call rather than a
202+
// special case further down.
203+
let predicates: Vec<Option<&str>> = if query.predicates.is_empty() {
204+
vec![None]
205+
} else {
206+
query.predicates.iter().map(|p| Some(p.as_str())).collect()
207+
};
208+
209+
// Nodes reached but never expanded, either because a bound was hit or
210+
// because they sit one hop past the requested depth. A set, not a
211+
// counter: the same boundary node is commonly reached from several
212+
// directions, and counting it twice would overstate what is left.
213+
let mut unexpanded: BTreeSet<String> = BTreeSet::new();
214+
215+
// Unseeded: an overview, not a traversal. One bounded scan of the
216+
// slice, and the node set is whatever the returned edges touch.
217+
if query.seeds.is_empty() {
218+
for predicate in &predicates {
219+
// One past the ceiling: a call that asked for exactly
220+
// `max_edges` and got them cannot tell a full slice from a
221+
// truncated one.
222+
let records = self
223+
.relations(
224+
namespace,
225+
None,
226+
*predicate,
227+
query.max_edges.saturating_add(1),
228+
)
229+
.await?;
230+
for record in records {
231+
push_view_edge(&mut view, record, &mut unexpanded, query, 0);
232+
}
233+
}
234+
view.stats.frontier_remaining = unexpanded.len();
235+
view.recompute_stats();
236+
return Ok(view);
237+
}
238+
239+
// Inbound edges are resolved from one scan per predicate rather than
240+
// one per node: the scan is the expensive part, and repeating it for
241+
// every node visited would multiply it by `max_nodes`.
242+
let mut inbound: Vec<GraphRelationRecord> = Vec::new();
243+
if query.direction.follows_in() {
244+
for predicate in &predicates {
245+
let records = self
246+
.relations(namespace, None, *predicate, INBOUND_SCAN_LIMIT)
247+
.await?;
248+
if records.len() >= INBOUND_SCAN_LIMIT {
249+
view.truncated = true;
250+
}
251+
inbound.extend(records);
252+
}
253+
}
254+
255+
let mut frontier: Vec<String> = Vec::new();
256+
for seed in &query.seeds {
257+
if view.nodes.iter().any(|n| &n.id == seed) {
258+
continue;
259+
}
260+
if view.nodes.len() >= query.max_nodes {
261+
unexpanded.insert(seed.clone());
262+
view.truncated = true;
263+
continue;
264+
}
265+
view.nodes.push(GraphNode::bare(seed.clone(), 0));
266+
frontier.push(seed.clone());
267+
}
268+
269+
for hop in 0..=query.depth {
270+
if frontier.is_empty() {
271+
break;
272+
}
273+
let mut next: Vec<String> = Vec::new();
274+
for node_id in &frontier {
275+
let mut incident: Vec<GraphRelationRecord> = Vec::new();
276+
if query.direction.follows_out() {
277+
for predicate in &predicates {
278+
incident.extend(
279+
self.relations(
280+
namespace,
281+
Some(node_id),
282+
*predicate,
283+
query.max_edges.saturating_add(1),
284+
)
285+
.await?,
286+
);
287+
}
288+
}
289+
if query.direction.follows_in() {
290+
incident.extend(
291+
inbound
292+
.iter()
293+
.filter(|record| &record.object == node_id)
294+
.cloned(),
295+
);
296+
}
297+
298+
for record in incident {
299+
if !query.accepts_predicate(&record.predicate) {
300+
continue;
301+
}
302+
let other = if &record.subject == node_id {
303+
record.object.clone()
304+
} else {
305+
record.subject.clone()
306+
};
307+
let known = view.nodes.iter().any(|n| n.id == other);
308+
if !known {
309+
// An edge to a node the view will not hold would
310+
// dangle, so it is dropped either way — but *why* it
311+
// was dropped matters. Reaching the requested depth is
312+
// the caller getting what they asked for; hitting the
313+
// node ceiling is not, and only the second makes the
314+
// view truncated. Conflating them would set the flag on
315+
// every finite traversal of a connected graph and leave
316+
// it saying nothing.
317+
if hop >= query.depth {
318+
unexpanded.insert(other);
319+
continue;
320+
}
321+
if view.nodes.len() >= query.max_nodes {
322+
unexpanded.insert(other);
323+
view.truncated = true;
324+
continue;
325+
}
326+
view.nodes.push(GraphNode::bare(other.clone(), hop + 1));
327+
next.push(other);
328+
}
329+
push_view_edge(&mut view, record, &mut unexpanded, query, hop);
330+
}
331+
}
332+
frontier = next;
333+
}
334+
335+
view.stats.frontier_remaining = unexpanded.len();
336+
view.recompute_stats();
337+
Ok(view)
338+
}
339+
}
340+
341+
/// Add one relation to a view, deduplicating by triple and honouring
342+
/// [`GraphViewQuery::max_edges`].
343+
///
344+
/// A separate function rather than a closure so the borrow of `view` ends
345+
/// between calls, which the traversal above needs while it is also pushing
346+
/// nodes.
347+
fn push_view_edge(
348+
view: &mut GraphView,
349+
record: GraphRelationRecord,
350+
unexpanded: &mut BTreeSet<String>,
351+
query: &GraphViewQuery,
352+
depth: u32,
353+
) {
354+
if !query.accepts_predicate(&record.predicate) {
355+
return;
356+
}
357+
let triple = (
358+
record.subject.clone(),
359+
record.predicate.clone(),
360+
record.object.clone(),
361+
);
362+
if view
363+
.edges
364+
.iter()
365+
.any(|e| e.key() == (&triple.0, &triple.1, &triple.2))
366+
{
367+
return;
368+
}
369+
if view.edges.len() >= query.max_edges {
370+
unexpanded.insert(triple.0);
371+
unexpanded.insert(triple.2);
372+
view.truncated = true;
373+
return;
374+
}
375+
// The unseeded overview derives its node set from the edges it found; the
376+
// seeded traversal has already placed both endpoints.
377+
for id in [triple.0.clone(), triple.2.clone()] {
378+
if view.nodes.iter().any(|n| n.id == id) {
379+
continue;
380+
}
381+
if view.nodes.len() >= query.max_nodes {
382+
unexpanded.insert(id);
383+
view.truncated = true;
384+
return;
385+
}
386+
view.nodes.push(GraphNode::bare(id, depth));
387+
}
388+
view.edges.push(GraphEdge::from(record));
143389
}
144390

145391
/// Snapshot capture and change computation over synced sources.

crates/tinymemory-api/src/provider/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ pub use chunks::{ChunkDetail, ChunkEmbedding, ChunkQuery, MemoryChunks};
7878
pub use content::{MemoryDocuments, MemoryIngest, MemoryTree};
7979
pub use driver::MemoryProvider;
8080
pub use episodic::{ConversationSegment, EpisodicTurn, MemoryEpisodic};
81-
pub use knowledge::{MemoryDiff, MemoryEntities, MemoryGraph};
81+
pub use knowledge::{MemoryDiff, MemoryEntities, MemoryGraph, INBOUND_SCAN_LIMIT};
8282
pub use mandatory::{MemoryCore, MemoryPortability, MemoryRecall};
8383
pub use people::{
8484
AddressBookSeedOutcome, MemoryPeople, PersonHandle, PersonInteraction, PersonRecord, PersonRef,

0 commit comments

Comments
 (0)