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
24 changes: 20 additions & 4 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ jobs:
- rdb_load
- cypher_parse
- conf_parse
- csr_from_bytes
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@nightly
Expand All @@ -42,15 +43,23 @@ jobs:
workspaces: fuzz
shared-key: fuzz-nightly
- name: Install cargo-fuzz
run: cargo install cargo-fuzz --locked
# Prebuilt binary: `cargo install --locked` broke when nightly started
# rejecting the lockfile's rustix 0.36.5 (reserved `rustc_*` attrs);
# a binary install is immune to toolchain drift and faster.
uses: taiki-e/install-action@4bc351f7f2614e48088386e2a0ad917ca3a7e4ba # v2.81.5
with:
tool: cargo-fuzz
- name: Download nightly corpus
uses: actions/download-artifact@v4
with:
name: fuzz-corpus
path: fuzz/corpus/
continue-on-error: true
- name: Run fuzzer (15m, multi-process)
run: cargo +nightly fuzz run "$TARGET" -- -max_total_time=900 -max_len=65536 -fork=2 -ignore_crashes=0
# Explicit --target: the prebuilt cargo-fuzz binary is musl-linked and
# self-detects musl as the default build target, whose std is not
# installed (E0463 "can't find crate for `core`").
run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu "$TARGET" -- -max_total_time=900 -max_len=65536 -fork=2 -ignore_crashes=0
env:
MOON_NO_URING: "1"
TARGET: ${{ matrix.target }}
Expand All @@ -72,6 +81,7 @@ jobs:
- rdb_load
- cypher_parse
- conf_parse
- csr_from_bytes
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@nightly
Expand All @@ -82,15 +92,21 @@ jobs:
workspaces: fuzz
shared-key: fuzz-nightly
- name: Install cargo-fuzz
run: cargo install cargo-fuzz --locked
# Prebuilt binary: `cargo install --locked` broke when nightly started
# rejecting the lockfile's rustix 0.36.5 (reserved `rustc_*` attrs);
# a binary install is immune to toolchain drift and faster.
uses: taiki-e/install-action@4bc351f7f2614e48088386e2a0ad917ca3a7e4ba # v2.81.5
with:
tool: cargo-fuzz
- name: Download previous corpus
uses: actions/download-artifact@v4
with:
name: fuzz-corpus
path: fuzz/corpus/
continue-on-error: true
- name: Run fuzzer (6h, multi-process)
run: cargo +nightly fuzz run "$TARGET" -- -max_total_time=21600 -max_len=65536 -fork=2 -ignore_crashes=0
# Explicit --target: see fuzz-pr note (prebuilt musl cargo-fuzz).
run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu "$TARGET" -- -max_total_time=21600 -max_len=65536 -fork=2 -ignore_crashes=0
env:
MOON_NO_URING: "1"
TARGET: ${{ matrix.target }}
Expand Down
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,46 @@ 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-only and
`GRAPH.PROFILE` paths via `ExecutionContext` (same pattern as
`VALID_AT`); write queries (CREATE/SET/DELETE/MERGE) reject the flag
instead of silently ignoring it.
- **`FT.NAVIGATE ... DECAY <λ>`** — graph-expanded hits pay
`λ × age_seconds` of their discovery edge on top of the hop penalty
(a re-rank of the already-explored expansion, not a steer of the
expansion itself); 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.

### Added — Ship moon as an installable application on macOS, Linux, and Windows

Five-PR milestone making moon installable on all three platforms
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,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; the flag is read-only-query only (rejected on CREATE/SET/DELETE/MERGE). `FT.NAVIGATE ... DECAY <λ>` applies the same penalty to the discovery edge of each graph-expanded hit — a re-rank of the already-explored expansion, not a steer of the expansion itself. 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
47 changes: 47 additions & 0 deletions docs/guides/temporal.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,52 @@ 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`, and `--decay`
is rejected on write queries (CREATE, SET, DELETE, MERGE) — it biases
read-path traversal only.
- The two surfaces apply decay differently: `GRAPH.QUERY --decay` **steers**
the traversal itself (the age term is part of the Dijkstra edge cost, so
decay changes which paths get explored), while `FT.NAVIGATE DECAY`
**re-ranks** hits the graph expansion already discovered — the BFS frontier
itself is not decay-aware, so nodes reachable only through stale edges
beyond the expansion budget are penalized, not replaced by fresher
alternatives. KNN (hop-0) hits are never decay-penalized.
- **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 +125,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 @@ -66,5 +66,10 @@ name = "conf_parse"
path = "fuzz_targets/conf_parse.rs"
doc = false

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

[workspace]
members = ["."]
31 changes: 31 additions & 0 deletions fuzz/fuzz_targets/csr_from_bytes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#![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.
// Tempfile I/O failures (disk full, fd exhaustion) are environment
// problems, not findings — skip the iteration instead of panicking.
if data.starts_with(b"MNGR") {
let Ok(temp) = tempfile::NamedTempFile::new() else {
return;
};
if temp.as_file().write_all(data).is_err() {
return;
}
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
}
}
});
Loading
Loading