Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
2 changes: 2 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ jobs:
- acl_rule
- rdb_load
- cypher_parse
- csr_from_bytes
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@nightly
Expand Down Expand Up @@ -70,6 +71,7 @@ jobs:
- acl_rule
- rdb_load
- cypher_parse
- csr_from_bytes
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@nightly
Expand Down
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,44 @@ The v0.2 enterprise beachhead. Built additively on per-shard WAL v3 + the
dual-root manifest; no changes to the KV hot path, MVCC, page format, or
transaction layer.

### Added — Temporal-decay traversal scoring (agent-memory recency)

User-facing recency bias for graph traversal: paths through recently
created edges win over stale ones. The decay engine (scorers, Dijkstra
composite cost) existed but was unreachable — `shortestPath()` hardcoded
`lambda = 0`. This wires it end to end.

- **`GRAPH.QUERY ... --decay <λ> [--time-weight <w>]`** — per-edge cost
becomes `|weight| + λ·w·age_seconds` for `shortestPath()`; λ is 1/seconds,
strictly validated. Decay off (no flag) keeps exact distance-only
behavior — the age term contributes zero to every edge cost, so path
choice is identical to pre-decay Moon. Applies to the read,
write-aware, and `GRAPH.PROFILE` paths via `ExecutionContext` (same
pattern as `VALID_AT`).
- **`FT.NAVIGATE ... DECAY <λ>`** — graph-expanded hits pay
`λ × age_seconds` of their discovery edge on top of the hop penalty;
KNN direct hits unaffected.
- **Edges stamp `created_ms` at insert** from the shard-cached clock
(zero syscall on the insert path). `0 = unknown` is decay-neutral —
pre-upgrade edges never look maximally old. Distinct from the
user-owned bi-temporal `valid_from`/`valid_to`.
- **CSR segment format v3** — per-edge `created_ms` array (parallel to
`col_indices`) survives freeze → disk → mmap → compaction, so decay
sees true edge age after segments rotate. v1/v2 files keep loading
(empty stamps = neutral); both parsers (heap + mmap zero-copy) are
version-gated, plus a new `csr_from_bytes` fuzz target.
- **Fixed (latent durability bug):** `compact_segments` stamped merged
segments `version: 1` while the serializer always writes 48-byte v2+
NodeMeta records — a vacuumed segment written to disk misparsed on
reload (panic in debug, silent node_meta corruption in release).
Merged segments are now v3 and carry per-edge stamps through dedup.
- Docs: `guides/temporal.mdx` decay section + `commands.mdx`; script
coverage in `test-commands.sh` (6 DECAY cases) and `test-consistency.sh`
(1/4/12-shard path-flip parity).
- Known gap: WAL-replayed not-yet-frozen edges re-stamp to replay time
on restart (newest edges look new — bias direction preserved);
CSR-resident edges keep exact age.

### Fixed — CodeRabbit PR #136 durability follow-ups + decomposition + test isolation (PR #144)

Closes the 8 CodeRabbit findings left open after PR #136, plus two PR #144-review
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Any Redis client connects out of the box.
- **Cross-store ACID transactions.** `TXN.BEGIN` / `TXN.COMMIT` / `TXN.ABORT` for atomic writes across KV, vector, and graph stores with undo-log rollback.
- **Workspace partitioning.** `WS CREATE` / `WS AUTH` — multi-tenant namespace isolation with transparent key prefixing and per-shard registries.
- **Durable message queues.** `MQ CREATE` / `MQ PUSH` / `MQ POP` / `MQ ACK` — at-least-once delivery with dead-letter queues, debounced triggers, and WAL-backed crash recovery.
- **Bi-temporal MVCC.** `TEMPORAL.SNAPSHOT_AT` / `TEMPORAL.INVALIDATE` — point-in-time queries across KV (`FT.SEARCH AS_OF`) and graph (`GRAPH.QUERY VALID_AT`).
- **Bi-temporal MVCC.** `TEMPORAL.SNAPSHOT_AT` / `TEMPORAL.INVALIDATE` — point-in-time queries across KV (`FT.SEARCH AS_OF`) and graph (`GRAPH.QUERY VALID_AT`), plus temporal-decay traversal scoring (`GRAPH.QUERY --decay`, `FT.NAVIGATE DECAY`) that biases path finding toward recently created edges.
- **Embedded web console.** 7-view React UI (Dashboard, Browser, Console, Vectors, Graph, Memory) served at `/ui/` — zero deployment, one binary. REST + WebSocket + SSE gateway with Bearer auth and rate limiting.

<p align="center">
Expand Down
10 changes: 10 additions & 0 deletions docs/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,16 @@ GRAPH.QUERY social "MATCH (a) RETURN a" VALID_AT 1713394800000
Cypher subset: MATCH, WHERE, RETURN, CREATE, DELETE, SET, MERGE, WITH, ORDER BY, LIMIT, SKIP. Supports hybrid graph+vector queries via `EXPAND GRAPH` clause in `FT.SEARCH`.
</Note>

Temporal-decay traversal scoring biases path finding toward recently created edges — the recency primitive for agent memory graphs:

```
GRAPH.QUERY g "MATCH p = shortestPath((a:P {name: 'A'})-[*..5]->(c:P {name: 'C'})) RETURN p" --decay 0.1
GRAPH.QUERY g "..." --decay 0.1 --time-weight 2.0 -- scale the age term
FT.NAVIGATE idx "*=>[KNN 5 @vec $q]" PARAMS 2 q <vec> HOPS 2 DECAY 0.1
```

`--decay <λ>` (1/seconds) adds `λ × edge_age_seconds` to each edge's traversal cost, so `shortestPath()` prefers fresh routes over stale ones at equal weight. `FT.NAVIGATE ... DECAY <λ>` applies the same penalty to the discovery edge of each graph-expanded hit. Decay off (no flag) keeps exact distance-only behavior. See the [temporal guide](/guides/temporal) for details.

## Server (12)

`CONFIG GET`, `CONFIG SET`, `DBSIZE`, `FLUSHDB`, `FLUSHALL`, `HELLO`, `CLIENT`, `OBJECT`, `DEBUG`, `SLOWLOG`, `WAIT`, `COMMAND DOCS`
Expand Down
38 changes: 38 additions & 0 deletions docs/guides/temporal.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,43 @@ OK
|--------|---------|-------------|
| `AS_OF <unix_ms>` | `FT.SEARCH` | Search vectors using the index state at the given timestamp |
| `VALID_AT <unix_ms>` | `GRAPH.QUERY` | Execute Cypher query against graph state valid at the given timestamp |
| `--decay <λ> [--time-weight <w>]` | `GRAPH.QUERY` | Bias `shortestPath()` toward recently created edges (temporal-decay scoring) |
| `DECAY <λ>` | `FT.NAVIGATE` | Penalize graph-expanded hits discovered over stale edges |

## Temporal-decay traversal scoring

Moon stamps every graph edge with its wall-clock creation time (from the
shard-cached clock — no syscall on the insert path). The decay knobs turn
that stamp into a recency bias for traversal — the primitive behind
"prefer what the agent learned recently" in agent memory graphs:

```bash
# shortestPath() cost per edge: |weight| + λ × w × edge_age_seconds
127.0.0.1:6379> GRAPH.QUERY social \
"MATCH p = shortestPath((a:Person {name: 'A'})-[*..5]->(c:Person {name: 'C'})) RETURN p" \
--decay 0.1

# Scale the age term independently of λ
127.0.0.1:6379> GRAPH.QUERY social "..." --decay 0.1 --time-weight 2.0

# FT.NAVIGATE: final_score += λ × age_seconds of the discovery edge
127.0.0.1:6379> FT.NAVIGATE idx "*=>[KNN 5 @vec $q]" PARAMS 2 q <vec> HOPS 2 DECAY 0.1
```

- `λ` is a decay rate in **1/seconds**; both surfaces validate it strictly
(finite, non-negative). `--time-weight` requires `--decay`.
- **Decay off (no flag) is exact distance-only behavior** — the age term
contributes zero to every edge cost, so path choice is identical to
pre-decay Moon.
- Edges with an unknown creation time (created before the upgrade, or loaded
from a pre-v3 segment file) are **neutral**: they pay no age penalty rather
than being treated as maximally old.
- Stamps survive the full segment lifecycle: mutable graph → frozen CSR
segment (format v3 stores a per-edge `created_ms` array) → disk → mmap →
compaction merges.
- This is **transaction-time recency** (when the edge was created), distinct
from the user-owned `valid_from`/`valid_to` bi-temporal valid-time used by
`VALID_AT` and `TEMPORAL.INVALIDATE`.

## How it works

Expand Down Expand Up @@ -79,3 +116,4 @@ Both record types are replayed on startup to reconstruct the temporal registry a
- Temporal snapshots consume memory proportional to the number of snapshots recorded. No automatic GC policy yet.
- `AS_OF` requires at least one `TEMPORAL.SNAPSHOT_AT` recorded at or before the requested timestamp.
- Bi-temporal fields are currently limited to graph entities (nodes/edges). KV temporal versioning uses a sparse index.
- Decay stamps on edges still in the mutable (not yet frozen) segment are re-stamped to replay time after a restart — only CSR-resident edges keep their exact creation time across restarts. Newest edges look new either way, so the bias direction is preserved.
32 changes: 31 additions & 1 deletion fuzz/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,10 @@ name = "cypher_parse"
path = "fuzz_targets/cypher_parse.rs"
doc = false

[[bin]]
name = "csr_from_bytes"
path = "fuzz_targets/csr_from_bytes.rs"
doc = false

[workspace]
members = ["."]
25 changes: 25 additions & 0 deletions fuzz/fuzz_targets/csr_from_bytes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#![no_main]
use libfuzzer_sys::fuzz_target;

use std::io::Write;

/// Fuzz the CSR graph segment decoders (heap + mmap).
///
/// Exercises magic/version validation, version-gated section sizing
/// (v1 32B NodeMeta, v2+ 48B, v3+ trailing edge_created_ms array),
/// checked-arithmetic length validation, CRC32 checksum, row_offsets
/// monotonicity checks, and index rebuilding. Any panic or OOB access
/// is a bug — corrupted segment files must fail with CsrError, never
/// crash recovery.
fuzz_target!(|data: &[u8]| {
// Heap parser: must return Err cleanly on any malformed input.
let _ = moon::graph::CsrSegment::from_bytes(data);

// Mmap parser (separate unsafe pointer-math code path): only worth a
// temp-file round trip for inputs that pass the first-byte gate.
if data.starts_with(b"MNGR") {
let temp = tempfile::NamedTempFile::new().unwrap();
temp.as_file().write_all(data).unwrap();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
let _ = moon::graph::MmapCsrSegment::from_mmap_file(temp.path());
}
});
5 changes: 2 additions & 3 deletions fuzz/fuzz_targets/rdb_load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,8 @@ fuzz_target!(|data: &[u8]| {
let temp = tempfile::NamedTempFile::new().unwrap();
temp.as_file().write_all(data).unwrap();

let mut databases: Vec<moon::storage::db::Database> = (0..1)
.map(|_| moon::storage::db::Database::new())
.collect();
let mut databases: Vec<moon::storage::db::Database> =
(0..1).map(|_| moon::storage::db::Database::new()).collect();

// Should not panic regardless of input
let _ = moon::persistence::rdb::load(&mut databases, temp.path());
Expand Down
4 changes: 2 additions & 2 deletions fuzz/fuzz_targets/resp_parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ fuzz_target!(|data: &[u8]| {
Ok(Some(_frame)) => {
// Parsed a valid frame — buffer advanced, try next
}
Ok(None) => break, // Incomplete — need more data
Err(_) => break, // Protocol error — stop
Ok(None) => break, // Incomplete — need more data
Err(_) => break, // Protocol error — stop
}
}
});
89 changes: 89 additions & 0 deletions scripts/test-commands.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1907,6 +1907,95 @@ PYEOF
echo " temporal: done"
fi

# ===========================================================================
# TEMPORAL DECAY (GRAPH.QUERY --decay / FT.NAVIGATE DECAY)
# ===========================================================================

if should_run "temporal"; then
echo ""
echo "=== TEMPORAL DECAY (GRAPH.QUERY --decay / FT.NAVIGATE DECAY) ==="
mcli FLUSHALL >/dev/null 2>&1

# Build the stale-direct vs fresh-detour graph:
# A -> C weight 1.0, created ~2s before the detour edges (stale direct)
# A -> B -> C weight 0.6 each, created last (fresh detour)
# Without --decay the cheaper direct path wins (1.0 < 1.2); with a decay
# rate the older direct edge pays lambda * age_seconds and the fresh
# detour wins. Real wall-clock sleep creates the age gap.
mcli GRAPH.CREATE decayg >/dev/null 2>&1
DECAY_A=$(mcli GRAPH.ADDNODE decayg Person name A 2>&1 | grep -oE '[0-9]+' | head -1)
DECAY_B=$(mcli GRAPH.ADDNODE decayg Person name B 2>&1 | grep -oE '[0-9]+' | head -1)
DECAY_C=$(mcli GRAPH.ADDNODE decayg Person name C 2>&1 | grep -oE '[0-9]+' | head -1)

mcli GRAPH.ADDEDGE decayg "$DECAY_A" "$DECAY_C" KNOWS WEIGHT 1.0 >/dev/null 2>&1
sleep 2
mcli GRAPH.ADDEDGE decayg "$DECAY_A" "$DECAY_B" KNOWS WEIGHT 0.6 >/dev/null 2>&1
mcli GRAPH.ADDEDGE decayg "$DECAY_B" "$DECAY_C" KNOWS WEIGHT 0.6 >/dev/null 2>&1

DECAY_QUERY="MATCH p = shortestPath((a:Person {name: 'A'})-[*..5]->(c:Person {name: 'C'})) RETURN p"

# The returned path renders as one node id per line; the detour is
# detected by whether B's node id appears in the path.

# DECAY-01: without --decay the direct path wins (A -> C, no B)
TOTAL=$((TOTAL + 1))
OFF_OUT=$(mcli GRAPH.QUERY decayg "$DECAY_QUERY" 2>&1)
if echo "$OFF_OUT" | grep -qE "^${DECAY_C}\$" && ! echo "$OFF_OUT" | grep -qE "^${DECAY_B}\$"; then
PASS=$((PASS + 1)); echo " PASS: GRAPH.QUERY shortestPath without --decay takes direct path (no B)"
else
FAIL=$((FAIL + 1)); echo " FAIL: expected direct A->C path without --decay: $OFF_OUT"
fi

# DECAY-02: with --decay the fresh detour wins (A -> B -> C)
TOTAL=$((TOTAL + 1))
ON_OUT=$(mcli GRAPH.QUERY decayg "$DECAY_QUERY" --decay 5 2>&1)
if echo "$ON_OUT" | grep -qE "^${DECAY_B}\$"; then
PASS=$((PASS + 1)); echo " PASS: GRAPH.QUERY shortestPath --decay 5 prefers fresh detour (via B)"
else
FAIL=$((FAIL + 1)); echo " FAIL: expected detour via B with --decay 5: $ON_OUT"
fi

# DECAY-03: --decay garbage value rejected
TOTAL=$((TOTAL + 1))
BAD_OUT=$(mcli GRAPH.QUERY decayg "$DECAY_QUERY" --decay abc 2>&1)
if echo "$BAD_OUT" | grep -qi "finite non-negative"; then
PASS=$((PASS + 1)); echo " PASS: GRAPH.QUERY --decay rejects non-numeric value"
else
FAIL=$((FAIL + 1)); echo " FAIL: --decay abc should be rejected: $BAD_OUT"
fi

# DECAY-04: --time-weight without --decay rejected
TOTAL=$((TOTAL + 1))
TW_OUT=$(mcli GRAPH.QUERY decayg "$DECAY_QUERY" --time-weight 2.0 2>&1)
if echo "$TW_OUT" | grep -qi "requires --decay"; then
PASS=$((PASS + 1)); echo " PASS: GRAPH.QUERY --time-weight without --decay rejected"
else
FAIL=$((FAIL + 1)); echo " FAIL: --time-weight alone should be rejected: $TW_OUT"
fi

# DECAY-05: FT.NAVIGATE DECAY strict validation (parses before index lookup)
TOTAL=$((TOTAL + 1))
NAV_BAD=$(mcli FT.NAVIGATE noidx "*" HOPS 2 DECAY notanumber 2>&1)
if echo "$NAV_BAD" | grep -qi "DECAY must be a finite non-negative number"; then
PASS=$((PASS + 1)); echo " PASS: FT.NAVIGATE DECAY rejects non-numeric value"
else
FAIL=$((FAIL + 1)); echo " FAIL: FT.NAVIGATE DECAY notanumber should be rejected: $NAV_BAD"
fi

# DECAY-06: FT.NAVIGATE with valid DECAY proceeds past parsing (the error,
# if any, is about the missing index — NOT about the DECAY value)
TOTAL=$((TOTAL + 1))
NAV_OK=$(mcli FT.NAVIGATE noidx "*" HOPS 2 DECAY 0.5 2>&1)
if echo "$NAV_OK" | grep -qi "DECAY"; then
FAIL=$((FAIL + 1)); echo " FAIL: valid DECAY 0.5 should not produce a DECAY error: $NAV_OK"
else
PASS=$((PASS + 1)); echo " PASS: FT.NAVIGATE DECAY 0.5 accepted (no DECAY parse error)"
fi

mcli GRAPH.DELETE decayg >/dev/null 2>&1
echo " temporal decay: done"
fi

# ===========================================================================
# WORKSPACE COMMANDS (WS CREATE/LIST/INFO/AUTH/DROP)
# ===========================================================================
Expand Down
Loading
Loading