diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 7761888b1..cb38cc785 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -32,6 +32,7 @@ jobs: - rdb_load - cypher_parse - conf_parse + - csr_from_bytes steps: - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@nightly @@ -42,7 +43,12 @@ 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: @@ -50,7 +56,10 @@ jobs: 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 }} @@ -72,6 +81,7 @@ jobs: - rdb_load - cypher_parse - conf_parse + - csr_from_bytes steps: - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@nightly @@ -82,7 +92,12 @@ 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: @@ -90,7 +105,8 @@ jobs: 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 }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 8192737ca..578f6aef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ]`** — 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 diff --git a/README.md b/README.md index 2549616dd..4a8376383 100644 --- a/README.md +++ b/README.md @@ -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.

diff --git a/docs/commands.mdx b/docs/commands.mdx index 4694cc699..5de9364dc 100644 --- a/docs/commands.mdx +++ b/docs/commands.mdx @@ -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`. +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 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` diff --git a/docs/guides/temporal.mdx b/docs/guides/temporal.mdx index 9121e875b..0fce5df72 100644 --- a/docs/guides/temporal.mdx +++ b/docs/guides/temporal.mdx @@ -45,6 +45,52 @@ OK |--------|---------|-------------| | `AS_OF ` | `FT.SEARCH` | Search vectors using the index state at the given timestamp | | `VALID_AT ` | `GRAPH.QUERY` | Execute Cypher query against graph state valid at the given timestamp | +| `--decay <λ> [--time-weight ]` | `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 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 @@ -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. diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 103ae3c84..93aadc3d1 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -1114,7 +1114,7 @@ dependencies = [ [[package]] name = "moon" -version = "0.1.0" +version = "0.1.12" dependencies = [ "anyhow", "arc-swap", @@ -1167,12 +1167,14 @@ dependencies = [ "smallvec", "socket2", "thiserror 2.0.18", + "tikv-jemalloc-ctl", "tikv-jemallocator", "tokio", "tokio-rustls", "tokio-util", "tracing", "tracing-subscriber", + "uuid", "xxhash-rust", ] @@ -1286,6 +1288,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "phf" version = "0.13.1" @@ -1884,6 +1892,17 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tikv-jemalloc-ctl" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "661f1f6a57b3a36dc9174a2c10f19513b4866816e13425d3e418b11cc37bc24c" +dependencies = [ + "libc", + "paste", + "tikv-jemalloc-sys", +] + [[package]] name = "tikv-jemalloc-sys" version = "0.6.1+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7" @@ -2069,6 +2088,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index aed89d5ec..f3427024e 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -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 = ["."] diff --git a/fuzz/fuzz_targets/csr_from_bytes.rs b/fuzz/fuzz_targets/csr_from_bytes.rs new file mode 100644 index 000000000..4511d7e3c --- /dev/null +++ b/fuzz/fuzz_targets/csr_from_bytes.rs @@ -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()); + } +}); diff --git a/fuzz/fuzz_targets/rdb_load.rs b/fuzz/fuzz_targets/rdb_load.rs index f9d4f21ea..c692f9f56 100644 --- a/fuzz/fuzz_targets/rdb_load.rs +++ b/fuzz/fuzz_targets/rdb_load.rs @@ -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 = (0..1) - .map(|_| moon::storage::db::Database::new()) - .collect(); + let mut databases: Vec = + (0..1).map(|_| moon::storage::db::Database::new()).collect(); // Should not panic regardless of input let _ = moon::persistence::rdb::load(&mut databases, temp.path()); diff --git a/fuzz/fuzz_targets/resp_parse.rs b/fuzz/fuzz_targets/resp_parse.rs index 9258221e6..a25da1c6c 100644 --- a/fuzz/fuzz_targets/resp_parse.rs +++ b/fuzz/fuzz_targets/resp_parse.rs @@ -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 } } }); diff --git a/scripts/test-commands.sh b/scripts/test-commands.sh index 37bfdb88a..34f61b915 100755 --- a/scripts/test-commands.sh +++ b/scripts/test-commands.sh @@ -1907,6 +1907,105 @@ 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 + + # DECAY-07: --decay on a write query rejected (read-only-traversal knob, + # never silently ignored) + TOTAL=$((TOTAL + 1)) + WR_OUT=$(mcli GRAPH.QUERY decayg "CREATE (:Person {name: 'X'})" --decay 0.5 2>&1) + if echo "$WR_OUT" | grep -qi "read-only"; then + PASS=$((PASS + 1)); echo " PASS: GRAPH.QUERY --decay on write query rejected" + else + FAIL=$((FAIL + 1)); echo " FAIL: --decay on CREATE should be rejected: $WR_OUT" + fi + + mcli GRAPH.DELETE decayg >/dev/null 2>&1 + echo " temporal decay: done" +fi + # =========================================================================== # WORKSPACE COMMANDS (WS CREATE/LIST/INFO/AUTH/DROP) # =========================================================================== diff --git a/scripts/test-consistency.sh b/scripts/test-consistency.sh index 560e804bf..000cda220 100755 --- a/scripts/test-consistency.sh +++ b/scripts/test-consistency.sh @@ -774,6 +774,9 @@ TEMP_INV_RESULT_12="" FT_ASOF_RESULT_1="" FT_ASOF_RESULT_4="" FT_ASOF_RESULT_12="" +DECAY_RESULT_1="" +DECAY_RESULT_4="" +DECAY_RESULT_12="" for NSHARDS in 1 4 12; do log " -- temporal shards=$NSHARDS --" @@ -856,6 +859,31 @@ PYEOF 12) FT_ASOF_RESULT_12="$FT_SIG" ;; esac + # Temporal decay parity: stale-direct vs fresh-detour shortestPath must + # flip identically under --decay on every shard config (graphs are + # shard-local; the decay knob rides ExecutionContext like VALID_AT). + # The returned path renders one node id per line — the detour is + # detected by whether B's node id appears. + redis-cli -p "$PORT_RUST" GRAPH.CREATE decayg >/dev/null 2>&1 + DECAY_A=$(redis-cli -p "$PORT_RUST" GRAPH.ADDNODE decayg Person name A 2>&1 | grep -oE '[0-9]+' | head -1) + DECAY_B=$(redis-cli -p "$PORT_RUST" GRAPH.ADDNODE decayg Person name B 2>&1 | grep -oE '[0-9]+' | head -1) + DECAY_C=$(redis-cli -p "$PORT_RUST" GRAPH.ADDNODE decayg Person name C 2>&1 | grep -oE '[0-9]+' | head -1) + redis-cli -p "$PORT_RUST" GRAPH.ADDEDGE decayg "$DECAY_A" "$DECAY_C" KNOWS WEIGHT 1.0 >/dev/null 2>&1 + sleep 2 + redis-cli -p "$PORT_RUST" GRAPH.ADDEDGE decayg "$DECAY_A" "$DECAY_B" KNOWS WEIGHT 0.6 >/dev/null 2>&1 + redis-cli -p "$PORT_RUST" GRAPH.ADDEDGE decayg "$DECAY_B" "$DECAY_C" KNOWS WEIGHT 0.6 >/dev/null 2>&1 + DECAY_Q="MATCH p = shortestPath((a:Person {name: 'A'})-[*..5]->(c:Person {name: 'C'})) RETURN p" + DECAY_OFF=$(redis-cli -p "$PORT_RUST" GRAPH.QUERY decayg "$DECAY_Q" 2>&1) + DECAY_ON=$(redis-cli -p "$PORT_RUST" GRAPH.QUERY decayg "$DECAY_Q" --decay 5 2>&1) + OFF_VIA_B="no"; echo "$DECAY_OFF" | grep -qE "^${DECAY_B}\$" && OFF_VIA_B="yes" + ON_VIA_B="no"; echo "$DECAY_ON" | grep -qE "^${DECAY_B}\$" && ON_VIA_B="yes" + case "$NSHARDS" in + 1) DECAY_RESULT_1="off_via_b=$OFF_VIA_B|on_via_b=$ON_VIA_B" ;; + 4) DECAY_RESULT_4="off_via_b=$OFF_VIA_B|on_via_b=$ON_VIA_B" ;; + 12) DECAY_RESULT_12="off_via_b=$OFF_VIA_B|on_via_b=$ON_VIA_B" ;; + esac + redis-cli -p "$PORT_RUST" GRAPH.DELETE decayg >/dev/null 2>&1 + stop_moon done @@ -910,6 +938,20 @@ else echo " 12-shard: $FT_ASOF_RESULT_12" fi +# DECAY consistency: decay-off takes the cheaper direct path (no B), +# decay-on flips through the fresh detour (via B), identically across +# shard configs. +if [[ "$DECAY_RESULT_1" == "off_via_b=no|on_via_b=yes" \ + && "$DECAY_RESULT_1" == "$DECAY_RESULT_4" && "$DECAY_RESULT_4" == "$DECAY_RESULT_12" ]]; then + PASS=$((PASS + 1)); echo " PASS: GRAPH.QUERY --decay path flip consistent across 1/4/12 shards" +else + FAIL=$((FAIL + 1)) + echo " FAIL: GRAPH.QUERY --decay cross-shard divergence (expected off_via_b=no|on_via_b=yes)" + echo " 1-shard: $DECAY_RESULT_1" + echo " 4-shard: $DECAY_RESULT_4" + echo " 12-shard: $DECAY_RESULT_12" +fi + # Restart moon with the originally-requested shard count so later sections work. start_moon_with_shards "$SHARDS" || true diff --git a/src/command/graph/graph_read.rs b/src/command/graph/graph_read.rs index a7125bc39..b071ead0a 100644 --- a/src/command/graph/graph_read.rs +++ b/src/command/graph/graph_read.rs @@ -30,6 +30,53 @@ fn parse_valid_at(args: &[Frame]) -> Option { None } +/// Parse optional `--decay ` and `--time-weight ` from +/// GRAPH.QUERY args into a `DecayConfig` (temporal-decay traversal scoring). +/// +/// Strict validation (unlike `parse_valid_at`'s silent-None: decay is a new +/// surface, so malformed input is an error, not a silent no-op): +/// - both values must parse as finite, non-negative f64 +/// - `--time-weight` without `--decay` is rejected +/// - a dangling flag with no value is rejected +/// +/// Returns `Ok(None)` when neither flag is present. +fn parse_decay(args: &[Frame]) -> Result, &'static str> { + fn flag_value(args: &[Frame], flag: &[u8]) -> Result, &'static str> { + for i in 0..args.len() { + if let Frame::BulkString(ref bs) = args[i] { + if bs.as_ref() == flag { + let Some(Frame::BulkString(val)) = args.get(i + 1) else { + return Err("ERR flag requires a value"); + }; + let parsed = std::str::from_utf8(val) + .ok() + .and_then(|s| s.trim().parse::().ok()); + return match parsed { + Some(v) if v.is_finite() && v >= 0.0 => Ok(Some(v)), + _ => Err("ERR value must be a finite non-negative number"), + }; + } + } + } + Ok(None) + } + + let lambda = flag_value(args, b"--decay") + .map_err(|_| "ERR --decay must be a finite non-negative number (1/seconds)")?; + let time_weight = flag_value(args, b"--time-weight") + .map_err(|_| "ERR --time-weight must be a finite non-negative number")?; + + match (lambda, time_weight) { + (None, None) => Ok(None), + (None, Some(_)) => Err("ERR --time-weight requires --decay"), + (Some(lambda_per_sec), tw) => Ok(Some(crate::graph::scoring::DecayConfig { + lambda_per_sec, + time_weight: tw.unwrap_or(1.0), + now_ms: crate::storage::entry::current_time_ms(), + })), + } +} + /// Parse `--params ` from GRAPH.QUERY args into executor `Value` map. /// /// Scans args for `--params` keyword followed by a JSON string. The JSON must be @@ -399,8 +446,13 @@ pub fn graph_query(store: &GraphStore, args: &[Frame]) -> Frame { let params = parse_params(args); let valid_at = parse_valid_at(args); + let decay = match parse_decay(args) { + Ok(d) => d, + Err(msg) => return Frame::Error(Bytes::from_static(msg.as_bytes())), + }; let ctx = cypher::executor::ExecutionContext { valid_time_as_of: valid_at, + decay, ..Default::default() }; let result = match cypher::executor::execute(graph, &plan, ¶ms, &ctx) { @@ -443,6 +495,19 @@ pub fn graph_query_write(store: &mut GraphStore, args: &[Frame]) -> Frame { } }; + // Decay biases read-path traversal cost only; reject before any side + // effect (LSN allocation, mutation) — same contract as the write branch + // of `graph_query_or_write`. + match parse_decay(args) { + Ok(None) => {} + Ok(Some(_)) => { + return Frame::Error(Bytes::from_static( + b"ERR --decay requires a read-only Cypher query", + )); + } + Err(msg) => return Frame::Error(Bytes::from_static(msg.as_bytes())), + } + let plan = match cypher::planner::compile(&query) { Ok(p) => p, Err(e) => { @@ -621,8 +686,19 @@ pub fn graph_query_or_write( let params = parse_params(args); let valid_at = parse_valid_at(args); + let decay = match parse_decay(args) { + Ok(d) => d, + Err(msg) => { + return ( + Frame::Error(Bytes::from_static(msg.as_bytes())), + Vec::new(), + Vec::new(), + ); + } + }; let ctx = cypher::executor::ExecutionContext { valid_time_as_of: valid_at, + decay, ..Default::default() }; let result = match cypher::executor::execute(graph, &plan, ¶ms, &ctx) { @@ -635,6 +711,29 @@ pub fn graph_query_or_write( (exec_result_to_frame(&result), Vec::new(), Vec::new()) } else { + // Decay biases read-path traversal cost only; a write query must not + // silently accept (or skip validating) the flag. Reject before any + // side effect (LSN allocation, mutation). + match parse_decay(args) { + Ok(None) => {} + Ok(Some(_)) => { + return ( + Frame::Error(Bytes::from_static( + b"ERR --decay requires a read-only Cypher query", + )), + Vec::new(), + Vec::new(), + ); + } + Err(msg) => { + return ( + Frame::Error(Bytes::from_static(msg.as_bytes())), + Vec::new(), + Vec::new(), + ); + } + } + // Write path: compile plan (no cache for writes), execute with mutations. let plan = match cypher::planner::compile(&query) { Ok(p) => p, @@ -930,8 +1029,13 @@ pub fn graph_profile(store: &GraphStore, args: &[Frame]) -> Frame { let params = std::collections::HashMap::new(); let valid_at = parse_valid_at(args); + let decay = match parse_decay(args) { + Ok(d) => d, + Err(msg) => return Frame::Error(Bytes::from_static(msg.as_bytes())), + }; let ctx = cypher::executor::ExecutionContext { valid_time_as_of: valid_at, + decay, ..Default::default() }; let profile = match cypher::executor::execute_profile(graph, &plan, ¶ms, &ctx) { diff --git a/src/command/graph/mod.rs b/src/command/graph/mod.rs index ccd24be8a..252d75ed2 100644 --- a/src/command/graph/mod.rs +++ b/src/command/graph/mod.rs @@ -253,6 +253,219 @@ mod tests { assert!(matches!(resp, Frame::Integer(_))); } + use crate::storage::entry::ClockPin; + + /// Build the stale-direct vs fresh-detour graph used by the decay tests. + /// Returns (store, node id of B) with edges: + /// A -> C weight 1.0, created at t=1s (stale direct) + /// A -> B weight 0.6, created at t=99s (fresh detour) + /// B -> C weight 0.6, created at t=99s + fn build_decay_graph() -> (GraphStore, i64) { + let mut store = GraphStore::new(); + dispatch_graph_command(&mut store, &make_cmd(&[b"GRAPH.CREATE", b"g"])); + + let mut add_node = |name: &[u8]| -> i64 { + let resp = dispatch_graph_command( + &mut store, + &make_cmd(&[b"GRAPH.ADDNODE", b"g", b"Person", b"name", name]), + ); + match resp { + Frame::Integer(id) => id, + other => panic!("expected node id, got {other:?}"), + } + }; + let a = add_node(b"A").to_string(); + let b_id = add_node(b"B"); + let b = b_id.to_string(); + let c = add_node(b"C").to_string(); + + let mut add_edge = |src: &str, dst: &str, weight: &[u8]| { + let resp = dispatch_graph_command( + &mut store, + &make_cmd(&[ + b"GRAPH.ADDEDGE", + b"g", + src.as_bytes(), + dst.as_bytes(), + b"KNOWS", + b"WEIGHT", + weight, + ]), + ); + assert!( + matches!(resp, Frame::Integer(_)), + "ADDEDGE failed: {resp:?}" + ); + }; + + { + let _pin = ClockPin::set(1, 1_000); + add_edge(&a, &c, b"1.0"); + } + { + let _pin = ClockPin::set(99, 99_000); + add_edge(&a, &b, b"0.6"); + add_edge(&b, &c, b"0.6"); + } + (store, b_id) + } + + const DECAY_QUERY: &[u8] = + b"MATCH p = shortestPath((a:Person {name: 'A'})-[*..5]->(c:Person {name: 'C'})) RETURN p"; + + #[test] + fn test_graph_query_decay_flag_changes_shortest_path() { + let (mut store, b_id) = build_decay_graph(); + let _pin = ClockPin::set(100, 100_000); + + let off = + dispatch_graph_command(&mut store, &make_cmd(&[b"GRAPH.QUERY", b"g", DECAY_QUERY])); + assert!( + !matches!(off, Frame::Error(_)), + "baseline query must succeed: {off:?}" + ); + + let on = dispatch_graph_command( + &mut store, + &make_cmd(&[b"GRAPH.QUERY", b"g", DECAY_QUERY, b"--decay", b"0.01"]), + ); + assert!( + !matches!(on, Frame::Error(_)), + "--decay query must succeed: {on:?}" + ); + + // Compare path content, not whole-frame debug strings: the stats row + // embeds execution_time_us, which differs between any two runs and + // would make a plain inequality assertion pass vacuously. + // Result frame shape: [header, rows-array, stats]. + let path_rows = |resp: &Frame| -> String { + match resp { + Frame::Array(items) => match items.get(1) { + Some(rows) => format!("{rows:?}"), + None => panic!("missing rows array: {resp:?}"), + }, + other => panic!("expected array result: {other:?}"), + } + }; + // Decay off picks the stale direct path A->C; decay on detours + // through the fresh B. Node B's id must appear only in the on-path. + let b_token = b_id.to_string(); + let off_rows = path_rows(&off); + let on_rows = path_rows(&on); + assert!( + !off_rows.contains(&b_token), + "decay-off must take the direct A->C path, got {off_rows}" + ); + assert!( + on_rows.contains(&b_token), + "decay-on must detour through B (id {b_token}), got {on_rows}" + ); + } + + #[test] + fn test_graph_query_decay_flag_rejects_garbage() { + let (mut store, _b_id) = build_decay_graph(); + for bad in [&b"nan"[..], b"-1", b"abc", b""] { + let resp = dispatch_graph_command( + &mut store, + &make_cmd(&[b"GRAPH.QUERY", b"g", DECAY_QUERY, b"--decay", bad]), + ); + assert!( + matches!(resp, Frame::Error(_)), + "--decay {:?} must be rejected, got {resp:?}", + String::from_utf8_lossy(bad) + ); + } + // --decay without a value is also malformed. + let resp = dispatch_graph_command( + &mut store, + &make_cmd(&[b"GRAPH.QUERY", b"g", DECAY_QUERY, b"--decay"]), + ); + assert!( + matches!(resp, Frame::Error(_)), + "dangling --decay must error" + ); + } + + #[test] + fn test_graph_query_time_weight_requires_decay_and_parses() { + let (mut store, _b_id) = build_decay_graph(); + let _pin = ClockPin::set(100, 100_000); + + // --time-weight scales the decay term; with decay present it parses. + let resp = dispatch_graph_command( + &mut store, + &make_cmd(&[ + b"GRAPH.QUERY", + b"g", + DECAY_QUERY, + b"--decay", + b"0.01", + b"--time-weight", + b"2.0", + ]), + ); + assert!(!matches!(resp, Frame::Error(_)), "valid combo: {resp:?}"); + + // --time-weight without --decay is meaningless -> error. + let resp = dispatch_graph_command( + &mut store, + &make_cmd(&[b"GRAPH.QUERY", b"g", DECAY_QUERY, b"--time-weight", b"2.0"]), + ); + assert!( + matches!(resp, Frame::Error(_)), + "--time-weight without --decay must error" + ); + } + + #[test] + fn test_graph_query_decay_flag_rejected_on_write_query() { + let (mut store, _b_id) = build_decay_graph(); + const WRITE_QUERY: &[u8] = b"CREATE (:Person {name: 'X'})"; + + // Valid --decay on a write query is rejected, not silently ignored. + let resp = dispatch_graph_command( + &mut store, + &make_cmd(&[b"GRAPH.QUERY", b"g", WRITE_QUERY, b"--decay", b"0.5"]), + ); + match &resp { + Frame::Error(msg) => assert!( + msg.windows(b"read-only".len()).any(|w| w == b"read-only"), + "error must say decay needs a read-only query, got {resp:?}" + ), + other => panic!("--decay on write query must error, got {other:?}"), + } + + // Garbage --decay on a write query is validated, not silently ignored. + let resp = dispatch_graph_command( + &mut store, + &make_cmd(&[b"GRAPH.QUERY", b"g", WRITE_QUERY, b"--decay", b"notanumber"]), + ); + assert!( + matches!(resp, Frame::Error(_)), + "garbage --decay on write query must error, got {resp:?}" + ); + + // The rejection happens before execution: still exactly the 3 nodes + // from build_decay_graph (A, B, C) -- no 'X' was created. + let check = dispatch_graph_command( + &mut store, + &make_cmd(&[b"GRAPH.QUERY", b"g", b"MATCH (n:Person) RETURN n"]), + ); + // Result frame shape: [header, rows-array, stats]. + let rows = match &check { + Frame::Array(items) => match items.get(1) { + Some(Frame::Array(rows)) => rows.len(), + other => panic!("expected rows array, got {other:?}"), + }, + other => panic!("verification query failed: {other:?}"), + }; + assert_eq!( + rows, 3, + "rejected write query must not have executed, got {check:?}" + ); + } + #[test] fn test_graph_neighbors() { let mut store = GraphStore::new(); diff --git a/src/command/vector_search/graph_expand.rs b/src/command/vector_search/graph_expand.rs index 614d1087e..9d9193201 100644 --- a/src/command/vector_search/graph_expand.rs +++ b/src/command/vector_search/graph_expand.rs @@ -27,6 +27,11 @@ pub struct ExpandedResult { pub vec_score: f32, /// Graph distance in hops from the nearest seed node. pub graph_hops: u32, + /// Wall-clock creation stamp (Unix millis) of the edge that discovered + /// this node (the last hop on the shortest discovery path). 0 = unknown + /// (pre-upgrade edge or CSR segment without per-edge stamps); decay + /// treats 0 as neutral. + pub edge_created_ms: u64, } /// Build a reverse map from NodeKey -> Bytes by inverting NamedGraph.key_to_node. @@ -65,8 +70,8 @@ pub fn expand_results_via_graph( // Track seed keys for dedup (seeds are excluded from results). let seed_set: HashSet<&[u8]> = seed_keys.iter().map(|(k, _)| k.as_ref()).collect(); - // Track minimum hops per discovered key across all seeds. - let mut discovered: HashMap = HashMap::new(); + // Track (min hops, discovery-edge created_ms) per key across all seeds. + let mut discovered: HashMap = HashMap::new(); // Load immutable CSR segments for the merge reader. let seg_guard = graph.segments.load(); @@ -95,7 +100,7 @@ pub fn expand_results_via_graph( }; // Process BFS results: skip the seed node (depth 0), map NodeKeys to Redis keys. - for &(visited_node, hop_depth, _) in &result.visited { + for &(visited_node, hop_depth, edge_used) in &result.visited { if hop_depth == 0 { continue; // skip the seed itself } @@ -110,10 +115,13 @@ pub fn expand_results_via_graph( continue; } - // Track minimum hop distance across all seeds. - let entry = discovered.entry(redis_key.clone()).or_insert(u32::MAX); - if hop_depth < *entry { - *entry = hop_depth; + // Track minimum hop distance across all seeds, carrying the + // wall-clock stamp of the edge that discovered the node on that + // shortest path (for FT.NAVIGATE DECAY re-ranking). + let edge_ms = edge_used.map(|e| e.created_ms).unwrap_or(0); + let entry = discovered.entry(redis_key.clone()).or_insert((u32::MAX, 0)); + if hop_depth < entry.0 { + *entry = (hop_depth, edge_ms); } } } @@ -121,10 +129,11 @@ pub fn expand_results_via_graph( // Build results sorted by graph_hops ascending. let mut results: Vec = discovered .into_iter() - .map(|(key, hops)| ExpandedResult { + .map(|(key, (hops, edge_created_ms))| ExpandedResult { key, vec_score: 0.0, graph_hops: hops, + edge_created_ms, }) .collect(); results.sort_by_key(|r| r.graph_hops); @@ -155,9 +164,47 @@ mod tests { key: Bytes::from_static(b"doc:1"), vec_score: 0.95, graph_hops: 2, + edge_created_ms: 0, }; let r2 = r.clone(); assert_eq!(r2.graph_hops, 2); assert!(format!("{:?}", r2).contains("doc:1")); } + + #[test] + fn test_expand_carries_discovery_edge_created_ms() { + // seed -> n1 over an edge created at t=42s: the expanded result for + // n1 must surface that edge's wall-clock stamp so FT.NAVIGATE DECAY + // can age it. + use smallvec::smallvec; + + let mut gs = crate::graph::store::GraphStore::new(); + gs.create_graph(Bytes::from_static(b"g"), 64_000, 0) + .expect("create"); + let graph = gs.get_graph_mut(b"g").expect("graph"); + + let seed = graph.write_buf.add_node(smallvec![0], smallvec![], None, 1); + let n1 = graph.write_buf.add_node(smallvec![0], smallvec![], None, 1); + + crate::storage::entry::tl_clock_set(42, 42_000); + graph + .write_buf + .add_edge(seed, n1, 1, 1.0, None, 2) + .expect("edge"); + crate::storage::entry::tl_clock_set(0, 0); + + graph.register_key(Bytes::from_static(b"doc:seed"), seed); + graph.register_key(Bytes::from_static(b"doc:n1"), n1); + + let graph = gs.get_graph(b"g").expect("graph"); + let seeds = vec![(Bytes::from_static(b"doc:seed"), 0.1f32)]; + let results = expand_results_via_graph(graph, &seeds, 2); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].key.as_ref(), b"doc:n1"); + assert_eq!( + results[0].edge_created_ms, 42_000, + "expansion must carry the discovery edge's created_ms" + ); + } } diff --git a/src/command/vector_search/navigate.rs b/src/command/vector_search/navigate.rs index b41a466e4..f1c6a3fa4 100644 --- a/src/command/vector_search/navigate.rs +++ b/src/command/vector_search/navigate.rs @@ -63,6 +63,12 @@ pub fn ft_navigate( // --- Parse HOP_PENALTY p (optional, default 0.1) --- let hop_penalty = parse_hop_penalty(args).unwrap_or(DEFAULT_HOP_PENALTY); + // --- Parse DECAY (optional, strict validation) --- + let decay = match parse_decay(args) { + Ok(d) => d, + Err(msg) => return Frame::Error(Bytes::from_static(msg.as_bytes())), + }; + // --- Parse K from the KNN query for result cap --- let k = parse_k_from_query(args).unwrap_or(10) as usize; @@ -89,7 +95,7 @@ pub fn ft_navigate( let expanded = expand_results_via_graph(graph, &seed_keys, hops); // --- Step 3: Re-rank and merge --- - build_navigate_response(&knn_result, &expanded, hop_penalty, k) + build_navigate_response(&knn_result, &expanded, hop_penalty, k, decay) } /// Parse HOPS N from args. Returns None if not found. @@ -120,6 +126,33 @@ fn parse_hop_penalty(args: &[Frame]) -> Option { None } +/// Parse `DECAY ` from args (temporal-decay re-ranking). +/// +/// Strict validation: the value must be a finite non-negative f64; a dangling +/// keyword or garbage value is an error (`Err`), absence is `Ok(None)`. +fn parse_decay(args: &[Frame]) -> Result, &'static str> { + for i in 0..args.len() { + if matches_keyword(&args[i], b"DECAY") { + let parsed = args.get(i + 1).and_then(extract_bulk).and_then(|b| { + std::str::from_utf8(&b) + .ok() + .and_then(|s| s.trim().parse::().ok()) + }); + return match parsed { + Some(v) if v.is_finite() && v >= 0.0 => { + Ok(Some(crate::graph::scoring::DecayConfig { + lambda_per_sec: v, + time_weight: 1.0, + now_ms: crate::storage::entry::current_time_ms(), + })) + } + _ => Err("ERR DECAY must be a finite non-negative number (1/seconds)"), + }; + } + } + Ok(None) +} + /// Extract K from the KNN query string: "*=>[KNN k @field $param]". fn parse_k_from_query(args: &[Frame]) -> Option { if args.len() < 2 { @@ -135,7 +168,7 @@ fn parse_k_from_query(args: &[Frame]) -> Option { num_str.parse::().ok() } -/// Build synthetic FT.SEARCH args by stripping HOPS, HOP_PENALTY keywords. +/// Build synthetic FT.SEARCH args by stripping HOPS, HOP_PENALTY, DECAY keywords. fn build_search_args(args: &[Frame]) -> Vec { let mut result = Vec::with_capacity(args.len()); let mut skip_next = false; @@ -144,7 +177,10 @@ fn build_search_args(args: &[Frame]) -> Vec { skip_next = false; continue; } - if matches_keyword(frame, b"HOPS") || matches_keyword(frame, b"HOP_PENALTY") { + if matches_keyword(frame, b"HOPS") + || matches_keyword(frame, b"HOP_PENALTY") + || matches_keyword(frame, b"DECAY") + { // Skip this keyword and its value. if i + 1 < args.len() { skip_next = true; @@ -223,6 +259,7 @@ fn build_navigate_response( expanded: &[ExpandedResult], hop_penalty: f32, k: usize, + decay: Option, ) -> Frame { let knn_items = match knn_response { Frame::Array(items) => items, @@ -267,11 +304,16 @@ fn build_navigate_response( if seen.contains_key(&er.key) { continue; // keep the KNN version (lower score, hop_depth=0) } - let final_score = if er.vec_score > 0.0 { + let mut final_score = if er.vec_score > 0.0 { er.vec_score + (er.graph_hops as f32 * hop_penalty) } else { er.graph_hops as f32 * hop_penalty }; + // DECAY: age the discovery edge — same formula and 0-neutral rule as + // the GRAPH.QUERY traversal path, via the single DecayConfig home. + if let Some(d) = decay { + final_score += d.age_penalty_ms(er.edge_created_ms) as f32; + } let idx = candidates.len(); candidates.push(RankedCandidate { key: er.key.clone(), @@ -345,6 +387,108 @@ fn extract_score_from_fields(fields: &Frame) -> f32 { mod tests { use super::*; + #[test] + fn test_navigate_decay_penalizes_stale_edges() { + // Two expanded hits at equal hop depth: the one reached over a stale + // edge must rank below the one reached over a fresh edge when DECAY + // is active, and tie without it. Penalty = lambda * time_weight * + // age_seconds, added to final_score (lower = better). + let knn = Frame::Array( + vec![ + Frame::Integer(1), + Frame::BulkString(Bytes::from_static(b"doc:knn")), + Frame::Array( + vec![ + Frame::BulkString(Bytes::from_static(b"__vec_score")), + Frame::BulkString(Bytes::from_static(b"0.05")), + ] + .into(), + ), + ] + .into(), + ); + let expanded = vec![ + super::super::graph_expand::ExpandedResult { + key: Bytes::from_static(b"doc:stale"), + vec_score: 0.0, + graph_hops: 1, + edge_created_ms: 1_000, // age 99s at now=100s + }, + super::super::graph_expand::ExpandedResult { + key: Bytes::from_static(b"doc:fresh"), + vec_score: 0.0, + graph_hops: 1, + edge_created_ms: 99_000, // age 1s at now=100s + }, + ]; + + let decay = crate::graph::scoring::DecayConfig { + lambda_per_sec: 0.01, + time_weight: 1.0, + now_ms: 100_000, + }; + + // With decay: knn (0.05) < fresh (0.1 + 0.01) < stale (0.1 + 0.99). + let resp = build_navigate_response(&knn, &expanded, 0.1, 10, Some(decay)); + let keys = response_keys(&resp); + assert_eq!(keys, vec!["doc:knn", "doc:fresh", "doc:stale"]); + + // Without decay: stale and fresh tie at 0.1 (stable sort keeps + // insertion order: stale first). + let resp = build_navigate_response(&knn, &expanded, 0.1, 10, None); + let keys = response_keys(&resp); + assert_eq!(keys, vec!["doc:knn", "doc:stale", "doc:fresh"]); + } + + /// Collect result keys from a navigate response frame. + fn response_keys(resp: &Frame) -> Vec { + let Frame::Array(items) = resp else { + panic!("expected Array, got {resp:?}"); + }; + let mut keys = Vec::new(); + let mut i = 1; + while i + 1 < items.len() { + if let Frame::BulkString(k) = &items[i] { + keys.push(String::from_utf8_lossy(k).into_owned()); + } + i += 2; + } + keys + } + + #[test] + fn test_parse_decay_keyword() { + let args = vec![ + Frame::BulkString(Bytes::from_static(b"myidx")), + Frame::BulkString(Bytes::from_static(b"*=>[KNN 10 @vec $v]")), + Frame::BulkString(Bytes::from_static(b"HOPS")), + Frame::BulkString(Bytes::from_static(b"2")), + Frame::BulkString(Bytes::from_static(b"DECAY")), + Frame::BulkString(Bytes::from_static(b"0.05")), + ]; + let parsed = parse_decay(&args).expect("valid"); + let cfg = parsed.expect("present"); + assert!((cfg.lambda_per_sec - 0.05).abs() < 1e-12); + assert!((cfg.time_weight - 1.0).abs() < 1e-12); + + // Absent -> Ok(None). + assert!(parse_decay(&args[..4]).expect("valid").is_none()); + + // Garbage values -> Err. + for bad in [&b"nan"[..], b"-0.5", b"abc", b""] { + let args = vec![ + Frame::BulkString(Bytes::from_static(b"myidx")), + Frame::BulkString(Bytes::from_static(b"DECAY")), + Frame::BulkString(Bytes::from(bad.to_vec())), + ]; + assert!( + parse_decay(&args).is_err(), + "DECAY {:?} must be rejected", + String::from_utf8_lossy(bad) + ); + } + } + #[test] fn test_parse_hops() { let args = vec![ diff --git a/src/command/vector_search/tests.rs b/src/command/vector_search/tests.rs index 4a620ce59..3c33aefe3 100644 --- a/src/command/vector_search/tests.rs +++ b/src/command/vector_search/tests.rs @@ -1771,6 +1771,7 @@ mod graph_expand_tests { key: Bytes::from_static(b"doc:neighbor"), vec_score: 0.0, graph_hops: 2, + edge_created_ms: 0, }]; let result = build_combined_response(&knn, &expanded); if let Frame::Array(items) = &result { diff --git a/src/graph/compaction.rs b/src/graph/compaction.rs index 0594f1118..daaaaf856 100644 --- a/src/graph/compaction.rs +++ b/src/graph/compaction.rs @@ -221,6 +221,9 @@ struct MergedEdge { edge_type: u16, flags: u16, created_lsn: u64, + /// Per-edge wall-clock stamp carried through the merge (0 = unknown, + /// e.g. input segment predates the version 3 format). + created_ms: u64, } /// Compact multiple CSR segments into one with Rabbit Order reordering. @@ -271,6 +274,7 @@ pub fn compact_segments( let seg_row_offsets = seg.row_offsets(); let seg_col_indices = seg.col_indices(); let seg_edge_meta = seg.edge_meta(); + let seg_edge_created_ms = seg.edge_created_ms(); let seg_validity = seg.validity(); for src_row in 0..seg.node_count() { let src_ext = seg_node_meta[src_row as usize].external_id; @@ -299,6 +303,8 @@ pub fn compact_segments( edge_type: em.edge_type, flags: em.flags, created_lsn: seg.created_lsn(), + // Checked access: empty slice for pre-v3 input segments. + created_ms: seg_edge_created_ms.get(edge_idx).copied().unwrap_or(0), }; edge_map @@ -352,9 +358,10 @@ pub fn compact_segments( let edge_count = reordered_edges.len(); - // Build col_indices and edge_meta. + // Build col_indices, edge_meta, and the parallel created_ms array. let mut col_indices = Vec::with_capacity(edge_count); let mut edge_meta_vec = Vec::with_capacity(edge_count); + let mut edge_created_ms_vec = Vec::with_capacity(edge_count); for e in &reordered_edges { col_indices.push(e.dst_row); edge_meta_vec.push(EdgeMeta { @@ -362,6 +369,7 @@ pub fn compact_segments( flags: e.flags, property_offset: 0, }); + edge_created_ms_vec.push(e.created_ms); } // Build node_meta with reordered positions. @@ -458,9 +466,13 @@ pub fn compact_segments( hasher.finalize() as u64 }; + // Current version: to_bytes always writes 48-byte v2+ NodeMeta records + // plus the per-edge created_ms section. Stamping an older version here + // would make from_bytes misparse the serialized segment (version 1 = + // 32-byte stride) — the exact bug fixed in the P3 durability fix. let header = GraphSegmentHeader { magic: *b"MNGR", - version: 1, + version: crate::graph::types::CSR_CURRENT_VERSION, node_count: node_count as u32, edge_count: edge_count as u32, min_node_id, @@ -471,6 +483,7 @@ pub fn compact_segments( validity_bitmap_offset: 0, created_lsn, checksum, + edge_created_ms_offset: 0, // populated during serialization }; // Build indexes from compacted data. MPH is empty (no NodeKeys in compaction). @@ -483,6 +496,7 @@ pub fn compact_segments( row_offsets, col_indices, edge_meta: edge_meta_vec, + edge_created_ms: edge_created_ms_vec, node_meta: node_meta_vec, validity, node_id_to_row, @@ -625,6 +639,24 @@ mod tests { CsrSegment::from_frozen(frozen, lsn).expect("ok") } + /// Like [`make_csr`] but each edge carries a pinned wall-clock stamp + /// (Unix millis) via the thread-local cached clock. + fn make_csr_stamped(node_count: usize, edges: &[(usize, usize, u64)], lsn: u64) -> CsrSegment { + let mut mg = MemGraph::new(100_000); + let mut keys = Vec::with_capacity(node_count); + for _ in 0..node_count { + keys.push(mg.add_node(smallvec![0], smallvec![], None, 1)); + } + for &(s, d, ms) in edges { + // RAII pin: an expect() failure must not leak a pinned clock + // into the next test on this thread. + let _pin = crate::storage::entry::ClockPin::set((ms / 1000) as u32, ms); + mg.add_edge(keys[s], keys[d], 1, 1.0, None, 2).expect("ok"); + } + let frozen = mg.freeze().expect("ok"); + CsrSegment::from_frozen(frozen, lsn).expect("ok") + } + #[test] fn test_merge_three_segments() { let seg1 = Arc::new(CsrStorage::from(make_csr(3, &[(0, 1), (1, 2)], 10))); @@ -641,6 +673,68 @@ mod tests { assert!(result.node_count() > 0); } + #[test] + fn test_compacted_segment_serialize_parse_roundtrip() { + // A merged segment must survive to_bytes -> from_bytes intact. + // Regression: compact_segments stamped `version: 1` while to_bytes + // always writes 48-byte v2+ NodeMeta records, so a reloaded merged + // segment misparsed node_meta with the 32-byte v1 stride (recovery + // serializes every immutable segment via write_to_file). + let seg1 = Arc::new(CsrStorage::from(make_csr(3, &[(0, 1), (1, 2)], 10))); + let seg2 = Arc::new(CsrStorage::from(make_csr(3, &[(0, 2)], 20))); + let config = CompactionConfig { + min_segments: 2, + max_segment_edges: 1_000_000, + }; + let merged = compact_segments(&[seg1, seg2], &config).expect("ok"); + + let parsed = CsrSegment::from_bytes(&merged.to_bytes()).expect("parse ok"); + assert_eq!(parsed.node_count(), merged.node_count()); + assert_eq!(parsed.edge_count(), merged.edge_count()); + let mut want: Vec = merged.node_meta.iter().map(|nm| nm.external_id).collect(); + let mut got: Vec = parsed.node_meta.iter().map(|nm| nm.external_id).collect(); + want.sort_unstable(); + got.sort_unstable(); + assert_eq!( + got, want, + "external_ids must round-trip through serialization" + ); + } + + #[test] + fn test_compact_carries_edge_created_ms() { + // Distinct edges from two segments keep their own wall-clock stamps + // through the merge — vacuum must not zero per-edge recency. + let seg1 = Arc::new(CsrStorage::from(make_csr_stamped(3, &[(0, 1, 10_000)], 10))); + let seg2 = Arc::new(CsrStorage::from(make_csr_stamped(3, &[(1, 2, 20_000)], 20))); + let config = CompactionConfig { + min_segments: 2, + max_segment_edges: 1_000_000, + }; + let merged = compact_segments(&[seg1, seg2], &config).expect("ok"); + + assert_eq!(merged.edge_created_ms.len(), merged.col_indices.len()); + let mut stamps = merged.edge_created_ms.clone(); + stamps.sort_unstable(); + assert_eq!(stamps, vec![10_000, 20_000]); + } + + #[test] + fn test_compact_dedup_keeps_winning_edge_stamp() { + // Same (src, dst, type) edge in both segments: dedup keeps the + // higher-LSN segment's edge AND its created_ms stamp. + let seg1 = Arc::new(CsrStorage::from(make_csr_stamped(2, &[(0, 1, 10_000)], 10))); + let seg2 = Arc::new(CsrStorage::from(make_csr_stamped(2, &[(0, 1, 20_000)], 20))); + let config = CompactionConfig { + min_segments: 2, + max_segment_edges: 1_000_000, + }; + let merged = compact_segments(&[seg1, seg2], &config).expect("ok"); + + assert_eq!(merged.edge_count(), 1); + assert_eq!(merged.edge_created_ms, vec![20_000]); + } + #[test] fn test_tombstoned_edges_dropped() { let mut seg = make_csr(3, &[(0, 1), (0, 2), (1, 2)], 10); diff --git a/src/graph/csr/mmap.rs b/src/graph/csr/mmap.rs index 320ae67ab..f13c514a1 100644 --- a/src/graph/csr/mmap.rs +++ b/src/graph/csr/mmap.rs @@ -31,6 +31,10 @@ pub struct MmapCsrSegment { /// Pointer into mmap: length = node_count. node_meta_ptr: *const NodeMeta, node_meta_len: usize, + /// Pointer into mmap: length = edge_count for version >= 3 segments, + /// 0 (dangling) for older files without per-edge created_ms stamps. + edge_created_ms_ptr: *const u64, + edge_created_ms_len: usize, /// Validity bitmap (heap-allocated, mutable). pub validity: RoaringBitmap, pub node_id_to_row: HashMap, @@ -91,11 +95,20 @@ impl MmapCsrSegment { let vb_offset = u64::from_le_bytes(read8(data, 56)?); let created_lsn = u64::from_le_bytes(read8(data, 64)?); let stored_checksum = u64::from_le_bytes(read8(data, 72)?); + // Version 3+: byte offset of the per-edge created_ms section (informational). + let ecms_offset = if version >= 3 { + u64::from_le_bytes(read8(data, 80)?) + } else { + 0 + }; let nc = node_count as usize; let ec = edge_count as usize; let em_elem_size = core::mem::size_of::(); // 8 - let nm_elem_size = core::mem::size_of::(); // 32 + let nm_elem_size = core::mem::size_of::(); // 48 (version 2+ layout; + // version 1 files fail the length check below and fall back to the heap loader) + // Version 3+: trailing per-edge created_ms array (8 bytes per edge). + let ecms_elem_size: usize = if version >= 3 { 8 } else { 0 }; // Use checked arithmetic to prevent integer overflow from attacker-controlled // node_count/edge_count values bypassing the size validation. @@ -104,11 +117,13 @@ impl MmapCsrSegment { let ci_size = ec.checked_mul(4)?; let em_size = ec.checked_mul(em_elem_size)?; let nm_size = nc.checked_mul(nm_elem_size)?; + let ecms_size = ec.checked_mul(ecms_elem_size)?; header_size .checked_add(ro_size)? .checked_add(ci_size)? .checked_add(em_size)? - .checked_add(nm_size) + .checked_add(nm_size)? + .checked_add(ecms_size) })() .ok_or_else(|| CsrError::InvalidData("size overflow in CSR header fields".to_owned()))?; if mmap.len() < expected_len { @@ -165,6 +180,26 @@ impl MmapCsrSegment { } let nm_len = nc; + // Version 3+: per-edge created_ms array after node_meta. Older files + // get a dangling pointer with len 0 — the accessor returns &[]. + let (ecms_ptr, ecms_len) = if version >= 3 { + let ecms_start = nm_start + nc * nm_elem_size; + // SAFETY: base + ecms_start is in-bounds (expected_len validated + // above includes ec * 8 for version >= 3), alignment checked below. + let p = unsafe { base.add(ecms_start) } as *const u64; + if !(p as usize).is_multiple_of(core::mem::align_of::()) { + return Err(CsrError::InvalidData( + "edge_created_ms alignment violated in mmap".to_owned(), + )); + } + (p, ec) + } else { + ( + core::ptr::NonNull::::dangling().as_ptr() as *const u64, + 0, + ) + }; + // Validate row_offsets: every entry must be <= edge_count and // the array must be monotonically non-decreasing. Without this, // corrupted CSR files cause panics via out-of-bounds slice indexing @@ -196,12 +231,20 @@ impl MmapCsrSegment { // SAFETY: em_ptr validated above (alignment checked, mmap alive for scope). let edge_meta_slice = unsafe { core::slice::from_raw_parts(em_ptr, em_len) }; + // Duplicate keys (corrupted/malicious file) must be rejected here: + // MphNodeIndex::build panics inside boomphf on non-unique keys + // (found by the csr_from_bytes fuzz target). let mut node_id_to_row: HashMap = HashMap::with_capacity(nc); let mut sorted_keys = Vec::with_capacity(nc); for (row, nm) in node_meta_slice.iter().enumerate() { let key_data = slotmap::KeyData::from_ffi(nm.external_id); let nk = NodeKey::from(key_data); - node_id_to_row.insert(nk, row as u32); + if node_id_to_row.insert(nk, row as u32).is_some() { + return Err(CsrError::InvalidData(format!( + "duplicate node external_id {} at row {row}", + nm.external_id + ))); + } sorted_keys.push(nk); } @@ -228,6 +271,7 @@ impl MmapCsrSegment { validity_bitmap_offset: vb_offset, created_lsn, checksum: stored_checksum, + edge_created_ms_offset: ecms_offset, }; Ok(Self { @@ -241,6 +285,8 @@ impl MmapCsrSegment { edge_meta_len: em_len, node_meta_ptr: nm_ptr, node_meta_len: nm_len, + edge_created_ms_ptr: ecms_ptr, + edge_created_ms_len: ecms_len, validity, node_id_to_row, mph, @@ -275,6 +321,16 @@ impl MmapCsrSegment { unsafe { core::slice::from_raw_parts(self.node_meta_ptr, self.node_meta_len) } } + /// Per-edge wall-clock creation stamps (version >= 3; empty for older files). + pub fn edge_created_ms(&self) -> &[u64] { + if self.edge_created_ms_len == 0 { + return &[]; + } + // SAFETY: pointer validated (bounds + alignment) in from_mmap_file for + // version >= 3; len 0 (dangling pointer) is handled by the early return. + unsafe { core::slice::from_raw_parts(self.edge_created_ms_ptr, self.edge_created_ms_len) } + } + /// Outgoing neighbor row indices for a CSR row. pub fn neighbors_out(&self, row: u32) -> &[u32] { let r = row as usize; diff --git a/src/graph/csr/mod.rs b/src/graph/csr/mod.rs index a58691eac..385137781 100644 --- a/src/graph/csr/mod.rs +++ b/src/graph/csr/mod.rs @@ -45,6 +45,11 @@ pub struct CsrSegment { pub col_indices: Vec, /// Parallel to col_indices. Per-edge metadata. pub edge_meta: Vec, + /// Parallel to col_indices. Wall-clock creation stamp (Unix millis) per + /// edge, used by temporal-decay traversal scoring. Empty for segments + /// loaded from version < 3 files (stamp unknown — decay treats it as + /// neutral). Access with `.get(idx)`, never by direct indexing. + pub edge_created_ms: Vec, /// Parallel to rows (length = node_count). Per-node metadata. pub node_meta: Vec, /// Validity bitmap: bit set = edge is live. One bit per edge. @@ -119,9 +124,10 @@ impl CsrSegment { row_offsets.push(offset); let edge_count = offset as usize; - // Build col_indices and edge_meta. + // Build col_indices, edge_meta, and the parallel created_ms array. let mut col_indices = Vec::with_capacity(edge_count); let mut edge_meta = Vec::with_capacity(edge_count); + let mut edge_created_ms = Vec::with_capacity(edge_count); for edges in &edges_by_src { for &(dst_row, edge) in edges { col_indices.push(dst_row); @@ -130,6 +136,10 @@ impl CsrSegment { flags: 0, property_offset: 0, }); + // Real wall-clock stamp from the mutable edge — survives + // compaction so decay scoring keeps true edge age (0 stays + // 0 = unknown for pre-upgrade edges). + edge_created_ms.push(edge.created_ms); } } @@ -176,7 +186,7 @@ impl CsrSegment { let header = GraphSegmentHeader { magic: *b"MNGR", - version: 2, + version: crate::graph::types::CSR_CURRENT_VERSION, node_count: node_count as u32, edge_count: edge_count as u32, min_node_id, @@ -187,6 +197,7 @@ impl CsrSegment { validity_bitmap_offset: 0, created_lsn: lsn, checksum, + edge_created_ms_offset: 0, // populated during serialization }; // Build indexes (Phase 116). @@ -200,6 +211,7 @@ impl CsrSegment { row_offsets, col_indices, edge_meta, + edge_created_ms, node_meta, validity, node_id_to_row, @@ -330,6 +342,7 @@ impl CsrSegment { /// Serialize the CSR segment to a contiguous byte buffer. /// Layout: header (128B) | row_offsets | col_indices | edge_meta | node_meta + /// | edge_created_ms (version >= 3 only) /// /// The CRC32 checksum covers the entire payload (header fields + /// all data arrays), ensuring corrupted array content is detected. @@ -339,15 +352,37 @@ impl CsrSegment { let ci_size = self.col_indices.len() * 4; let em_size = self.edge_meta.len() * core::mem::size_of::(); let nm_size = self.node_meta.len() * core::mem::size_of::(); + // Per-edge created_ms section only exists in version >= 3 files. + // Always edge_count entries (zero-filled when stamps are unknown). + // Invariant: stamps are either absent entirely (pre-v3 source, the + // loop below zero-fills) or exactly parallel to col_indices — a + // partial array means a construction-site bug, not a valid state. + debug_assert!( + self.edge_created_ms.is_empty() || self.edge_created_ms.len() == self.col_indices.len(), + "edge_created_ms ({}) must be empty or parallel to col_indices ({})", + self.edge_created_ms.len(), + self.col_indices.len() + ); + let write_ecms = self.header.version >= 3; + let ecms_size = if write_ecms { + self.col_indices.len() * 8 + } else { + 0 + }; - let total = header_size + ro_size + ci_size + em_size + nm_size; + let total = header_size + ro_size + ci_size + em_size + nm_size + ecms_size; let mut buf = Vec::with_capacity(total); // Write header with computed offsets (checksum placeholder = 0). let ro_offset = header_size as u64; let ci_offset = ro_offset + ro_size as u64; let em_offset = ci_offset + ci_size as u64; - let _nm_offset = em_offset + em_size as u64; + let nm_offset = em_offset + em_size as u64; + let ecms_offset = if write_ecms { + nm_offset + nm_size as u64 + } else { + 0 + }; buf.extend_from_slice(&self.header.magic); buf.extend_from_slice(&self.header.version.to_le_bytes()); @@ -361,6 +396,7 @@ impl CsrSegment { buf.extend_from_slice(&0u64.to_le_bytes()); // validity_bitmap_offset placeholder buf.extend_from_slice(&self.header.created_lsn.to_le_bytes()); buf.extend_from_slice(&0u64.to_le_bytes()); // checksum placeholder — filled below + buf.extend_from_slice(&ecms_offset.to_le_bytes()); // 0 for version < 3 // Pad header to 128 bytes. while buf.len() < header_size { @@ -384,7 +420,7 @@ impl CsrSegment { buf.extend_from_slice(&em.property_offset.to_le_bytes()); } - // Write node_meta (version 2: 48 bytes per entry with bi-temporal fields). + // Write node_meta (version 2+: 48 bytes per entry with bi-temporal fields). for nm in &self.node_meta { buf.extend_from_slice(&nm.external_id.to_le_bytes()); buf.extend_from_slice(&nm.label_bitmap.to_le_bytes()); @@ -395,6 +431,16 @@ impl CsrSegment { buf.extend_from_slice(&nm.valid_to.to_le_bytes()); } + // Write per-edge created_ms (version >= 3). Zero-fill if the segment + // carries no stamps (e.g. loaded from a v2 file then re-serialized) so + // the section length always matches edge_count. + if write_ecms { + for i in 0..self.col_indices.len() { + let ms = self.edge_created_ms.get(i).copied().unwrap_or(0); + buf.extend_from_slice(&ms.to_le_bytes()); + } + } + // Compute CRC32 over the entire buffer (with checksum field zeroed), // then write the checksum at offset 72. let checksum = compute_csr_checksum(&buf) as u64; @@ -437,6 +483,12 @@ impl CsrSegment { let _vb_offset = u64::from_le_bytes(read8(data, 56)?); let created_lsn = u64::from_le_bytes(read8(data, 64)?); let stored_checksum = u64::from_le_bytes(read8(data, 72)?); + // Version 3+: byte offset of the per-edge created_ms section (informational). + let ecms_offset = if version >= 3 { + u64::from_le_bytes(read8(data, 80)?) + } else { + 0 + }; let nc = node_count as usize; let ec = edge_count as usize; @@ -444,6 +496,8 @@ impl CsrSegment { // Version 1: 32-byte NodeMeta (no bi-temporal fields). // Version 2+: 48-byte NodeMeta (includes valid_from and valid_to). let nm_elem_size: usize = if version >= 2 { 48 } else { 32 }; + // Version 3+: trailing per-edge created_ms array (8 bytes per edge). + let ecms_elem_size: usize = if version >= 3 { 8 } else { 0 }; // Use checked arithmetic to prevent integer overflow from attacker-controlled // node_count/edge_count values bypassing the size validation. @@ -452,11 +506,13 @@ impl CsrSegment { let ci_size = ec.checked_mul(4)?; let em_size = ec.checked_mul(em_elem_size)?; let nm_size = nc.checked_mul(nm_elem_size)?; + let ecms_size = ec.checked_mul(ecms_elem_size)?; header_size .checked_add(ro_size)? .checked_add(ci_size)? .checked_add(em_size)? - .checked_add(nm_size) + .checked_add(nm_size)? + .checked_add(ecms_size) })() .ok_or_else(|| CsrError::InvalidData("size overflow in CSR header fields".to_owned()))?; if data.len() < expected_len { @@ -553,6 +609,17 @@ impl CsrSegment { pos += nm_elem_size; } + // Parse per-edge created_ms (version >= 3). Older files leave the + // Vec empty — readers treat a missing stamp as 0 = unknown/neutral. + let mut edge_created_ms = Vec::new(); + if version >= 3 { + edge_created_ms.reserve_exact(ec); + for _ in 0..ec { + edge_created_ms.push(u64::from_le_bytes(read8(data, pos)?)); + pos += 8; + } + } + // Rebuild validity bitmap: all edges valid (fresh load). let mut validity = RoaringBitmap::new(); for i in 0..ec as u32 { @@ -562,12 +629,20 @@ impl CsrSegment { // Rebuild node_id_to_row from node_meta external_id. // The external_id is the raw u64 from NodeKey::data().as_ffi(). // We need to reconstruct NodeKey from the u64 -- use KeyData::from_ffi. + // Duplicate keys (corrupted/malicious file) must be rejected here: + // MphNodeIndex::build panics inside boomphf on non-unique keys + // (found by the csr_from_bytes fuzz target). let mut node_id_to_row: HashMap = HashMap::with_capacity(nc); let mut sorted_keys = Vec::with_capacity(nc); for (row, nm) in node_meta.iter().enumerate() { let key_data = slotmap::KeyData::from_ffi(nm.external_id); let nk = NodeKey::from(key_data); - node_id_to_row.insert(nk, row as u32); + if node_id_to_row.insert(nk, row as u32).is_some() { + return Err(CsrError::InvalidData(format!( + "duplicate node external_id {} at row {row}", + nm.external_id + ))); + } sorted_keys.push(nk); } @@ -589,6 +664,7 @@ impl CsrSegment { validity_bitmap_offset: _vb_offset, created_lsn, checksum: stored_checksum, + edge_created_ms_offset: ecms_offset, }; Ok(Self { @@ -596,6 +672,7 @@ impl CsrSegment { row_offsets, col_indices, edge_meta, + edge_created_ms, node_meta, validity, node_id_to_row, @@ -688,7 +765,7 @@ mod tests { assert_eq!(csr.node_count(), 5); assert_eq!(csr.edge_count(), 10); assert_eq!(csr.header.magic, *b"MNGR"); - assert_eq!(csr.header.version, 2); + assert_eq!(csr.header.version, 3); } #[test] @@ -746,7 +823,7 @@ mod tests { // Read back header fields. assert_eq!(&bytes[0..4], b"MNGR"); let version = u32::from_le_bytes(bytes[4..8].try_into().expect("4 bytes")); - assert_eq!(version, 2); + assert_eq!(version, 3); let nc = u32::from_le_bytes(bytes[8..12].try_into().expect("4 bytes")); assert_eq!(nc, 5); let ec = u32::from_le_bytes(bytes[12..16].try_into().expect("4 bytes")); @@ -900,12 +977,13 @@ mod tests { let restored = CsrSegment::from_bytes(&bytes).expect("from_bytes ok"); assert_eq!(restored.header.magic, *b"MNGR"); - assert_eq!(restored.header.version, 2); + assert_eq!(restored.header.version, 3); assert_eq!(restored.node_count(), original.node_count()); assert_eq!(restored.edge_count(), original.edge_count()); assert_eq!(restored.created_lsn, 42); assert_eq!(restored.row_offsets, original.row_offsets); assert_eq!(restored.col_indices, original.col_indices); + assert_eq!(restored.edge_created_ms, original.edge_created_ms); // Verify neighbor queries still work. for row in 0..restored.node_count() { @@ -1087,16 +1165,17 @@ mod tests { #[test] fn test_csr_v1_migration_zero_fill() { - // Build a v2 CSR, serialize to bytes, then manually construct v1-format - // bytes by: patching version to 1, truncating each NodeMeta from 48 to - // 32 bytes (dropping valid_from/valid_to), and recomputing the CRC. + // Build a current-version CSR, serialize to bytes, then manually + // construct v1-format bytes by: patching version to 1, truncating each + // NodeMeta from 48 to 32 bytes (dropping valid_from/valid_to), omitting + // the v3 edge_created_ms section, and recomputing the CRC. let frozen = build_small_graph(); let csr = CsrSegment::from_frozen(frozen, 42).expect("csr ok"); - let v2_bytes = csr.to_bytes(); + let v3_bytes = csr.to_bytes(); - // Verify it is version 2. - let ver = u32::from_le_bytes(v2_bytes[4..8].try_into().expect("4 bytes")); - assert_eq!(ver, 2); + // Verify it is version 3. + let ver = u32::from_le_bytes(v3_bytes[4..8].try_into().expect("4 bytes")); + assert_eq!(ver, 3); let header_size = core::mem::size_of::(); // 128 let nc = csr.node_count() as usize; @@ -1113,17 +1192,18 @@ mod tests { let nm_start = header_size + ro_size + ci_size + em_size; // Build v1 bytes: header + arrays (ro + ci + em) + truncated node_meta. + // The v3 edge_created_ms section (after node_meta) is dropped. let mut v1_bytes = Vec::with_capacity(nm_start + nc * nm_v1_elem); // Copy everything up to node_meta unchanged. - v1_bytes.extend_from_slice(&v2_bytes[..nm_start]); + v1_bytes.extend_from_slice(&v3_bytes[..nm_start]); // For each NodeMeta, copy only the first 32 bytes (skip valid_from/valid_to). for i in 0..nc { let offset = nm_start + i * nm_v2_elem; - v1_bytes.extend_from_slice(&v2_bytes[offset..offset + nm_v1_elem]); + v1_bytes.extend_from_slice(&v3_bytes[offset..offset + nm_v1_elem]); } - // Patch version field at offset 4 from 2 to 1. + // Patch version field at offset 4 down to 1. v1_bytes[4..8].copy_from_slice(&1u32.to_le_bytes()); // Recompute CRC with checksum field zeroed. @@ -1136,6 +1216,8 @@ mod tests { assert_eq!(restored.header.version, 1); assert_eq!(restored.node_count(), csr.node_count()); assert_eq!(restored.edge_count(), csr.edge_count()); + // Pre-v3 files carry no per-edge stamps: empty = unknown (decay-neutral). + assert!(restored.edge_created_ms.is_empty()); // All node_meta entries must have valid_from=0, valid_to=i64::MAX. for (i, nm) in restored.node_meta.iter().enumerate() { @@ -1181,7 +1263,7 @@ mod tests { let frozen = g.freeze().expect("freeze ok"); let csr = CsrSegment::from_frozen(frozen, 50).expect("csr ok"); - assert_eq!(csr.header.version, 2); + assert_eq!(csr.header.version, 3); // Verify from_frozen propagated temporal fields. // Note: sorted_nodes order may differ from insertion order, @@ -1203,7 +1285,7 @@ mod tests { // Serialize and deserialize. let bytes = csr.to_bytes(); let restored = CsrSegment::from_bytes(&bytes).expect("roundtrip ok"); - assert_eq!(restored.header.version, 2); + assert_eq!(restored.header.version, 3); // Verify temporal fields survive roundtrip. for (i, nm) in restored.node_meta.iter().enumerate() { @@ -1217,4 +1299,161 @@ mod tests { ); } } + + // --- P3: per-edge created_ms fidelity (temporal-decay scoring) --- + + use crate::storage::entry::ClockPin; + + /// 2 nodes, 3 edges with distinct pinned wall-clock stamps. + /// Shape chosen so node_meta lands 8-aligned (nc+1+ec even) and the + /// mmap loader succeeds — exercising the zero-copy v3 section. + fn build_stamped_graph() -> FrozenMemGraph { + let mut g = MemGraph::new(100); + let a = g.add_node(smallvec![0], smallvec![], None, 1); + let b = g.add_node(smallvec![1], smallvec![], None, 1); + { + let _pin = ClockPin::set(10, 10_000); + g.add_edge(a, b, 1, 1.0, None, 2).expect("ok"); + } + { + let _pin = ClockPin::set(20, 20_000); + g.add_edge(b, a, 2, 0.5, None, 2).expect("ok"); + } + { + let _pin = ClockPin::set(30, 30_000); + g.add_edge(a, b, 3, 2.0, None, 2).expect("ok"); + } + g.freeze().expect("freeze ok") + } + + #[test] + fn test_from_frozen_populates_edge_created_ms() { + let frozen = build_stamped_graph(); + let csr = CsrSegment::from_frozen(frozen, 100).expect("csr ok"); + + assert_eq!(csr.header.version, 3); + assert_eq!(csr.edge_created_ms.len(), csr.col_indices.len()); + let mut stamps = csr.edge_created_ms.clone(); + stamps.sort_unstable(); + assert_eq!(stamps, vec![10_000, 20_000, 30_000]); + } + + #[test] + fn test_v3_roundtrip_preserves_edge_created_ms() { + let frozen = build_stamped_graph(); + let csr = CsrSegment::from_frozen(frozen, 100).expect("csr ok"); + + // Heap parse path. + let restored = CsrSegment::from_bytes(&csr.to_bytes()).expect("parse ok"); + assert_eq!(restored.edge_created_ms, csr.edge_created_ms); + + // File path (mmap-first). nc=2, ec=3 -> node_meta is 8-aligned, so + // this must take the zero-copy Mmap variant, not the heap fallback. + let dir = tempfile::TempDir::new().expect("tmpdir"); + let path = dir.path().join("stamped.csr"); + csr.write_to_file(&path).expect("write ok"); + let storage = CsrStorage::from_file(&path).expect("load ok"); + assert!( + matches!(storage, CsrStorage::Mmap(_)), + "expected mmap load for 8-aligned shape" + ); + assert_eq!(storage.edge_created_ms(), csr.edge_created_ms.as_slice()); + } + + /// Construct v2-format bytes (no edge_created_ms section) from a v3 + /// segment: strip the trailing section, patch version to 2, zero the v3 + /// edge_created_ms_offset header field (byte 80 — a v2 writer never set + /// it), recompute the CRC. + fn downgrade_to_v2(csr: &CsrSegment) -> Vec { + let v3_bytes = csr.to_bytes(); + let ec = csr.edge_count() as usize; + let mut v2_bytes = v3_bytes[..v3_bytes.len() - ec * 8].to_vec(); + v2_bytes[4..8].copy_from_slice(&2u32.to_le_bytes()); + v2_bytes[80..88].copy_from_slice(&0u64.to_le_bytes()); + v2_bytes[72..80].copy_from_slice(&0u64.to_le_bytes()); + let checksum = compute_csr_checksum(&v2_bytes) as u64; + v2_bytes[72..80].copy_from_slice(&checksum.to_le_bytes()); + v2_bytes + } + + #[test] + fn test_v2_bytes_parse_with_empty_edge_created_ms() { + // Old (v2) segment files must keep loading after the v3 upgrade: + // the parser accepts them with empty stamps and the graph intact. + let frozen = build_stamped_graph(); + let csr = CsrSegment::from_frozen(frozen, 100).expect("csr ok"); + let v2_bytes = downgrade_to_v2(&csr); + + // Heap parse path. + let restored = CsrSegment::from_bytes(&v2_bytes).expect("v2 parse ok"); + assert_eq!(restored.header.version, 2); + assert!(restored.edge_created_ms.is_empty()); + assert_eq!(restored.node_count(), csr.node_count()); + assert_eq!(restored.edge_count(), csr.edge_count()); + assert_eq!(restored.row_offsets, csr.row_offsets); + assert_eq!(restored.col_indices, csr.col_indices); + + // Mmap path: same bytes from a file must load with empty stamps. + let dir = tempfile::TempDir::new().expect("tmpdir"); + let path = dir.path().join("v2.csr"); + std::fs::write(&path, &v2_bytes).expect("write ok"); + let storage = CsrStorage::from_file(&path).expect("load ok"); + assert!(storage.edge_created_ms().is_empty()); + assert_eq!(storage.edge_count(), csr.edge_count()); + } + + #[test] + fn test_v2_reserialization_zero_fills_stamps() { + // A segment loaded from a v2 file then re-serialized keeps its + // (now version 2) header — to_bytes must not emit the v3 section + // for it, and the round-trip stays consistent. + let frozen = build_stamped_graph(); + let csr = CsrSegment::from_frozen(frozen, 100).expect("csr ok"); + let v2_bytes = downgrade_to_v2(&csr); + + let loaded = CsrSegment::from_bytes(&v2_bytes).expect("v2 parse ok"); + let rewritten = loaded.to_bytes(); + assert_eq!(rewritten, v2_bytes, "v2 re-serialization must be stable"); + } + + #[test] + fn test_duplicate_external_id_rejected_not_panic() { + // Found by the csr_from_bytes fuzz target (CI, 217K execs): a + // corrupted/malicious segment file whose node_meta carries duplicate + // external_ids fed duplicate keys into MphNodeIndex::build, which + // panics inside boomphf. Corrupted files must fail with CsrError, + // never crash recovery. + let frozen = build_stamped_graph(); + let csr = CsrSegment::from_frozen(frozen, 100).expect("csr ok"); + let mut bytes = csr.to_bytes(); + + // node_meta starts after header(128) + row_offsets(4*(nc+1)) + + // col_indices(4*ec) + edge_meta(8*ec); external_id is the first + // field of each 48-byte NodeMeta record. + let nc = csr.node_count() as usize; + let ec = csr.edge_count() as usize; + let nm_start = 128 + 4 * (nc + 1) + 4 * ec + 8 * ec; + // Overwrite node 1's external_id with node 0's. + let id0: [u8; 8] = bytes[nm_start..nm_start + 8].try_into().expect("8 bytes"); + bytes[nm_start + 48..nm_start + 48 + 8].copy_from_slice(&id0); + // Recompute the checksum (offset 72) over the patched payload. + bytes[72..80].copy_from_slice(&0u64.to_le_bytes()); + let checksum = compute_csr_checksum(&bytes) as u64; + bytes[72..80].copy_from_slice(&checksum.to_le_bytes()); + + // Heap parser: must return Err, not panic. + assert!( + CsrSegment::from_bytes(&bytes).is_err(), + "duplicate external_id must be rejected by from_bytes" + ); + + // Mmap parser: same bytes from a file must also fail cleanly. + let dir = tempfile::TempDir::new().expect("tmpdir"); + let path = dir.path().join("dup.csr"); + std::fs::write(&path, &bytes).expect("write ok"); + assert!( + MmapCsrSegment::from_mmap_file(&path).is_err(), + "duplicate external_id must be rejected by the mmap loader" + ); + } } diff --git a/src/graph/csr/storage.rs b/src/graph/csr/storage.rs index 21c491966..34b2da860 100644 --- a/src/graph/csr/storage.rs +++ b/src/graph/csr/storage.rs @@ -58,6 +58,15 @@ impl CsrStorage { } } + /// Per-edge wall-clock creation stamps, parallel to col_indices. + /// Empty for segments loaded from version < 3 files (stamp unknown). + pub fn edge_created_ms(&self) -> &[u64] { + match self { + CsrStorage::Heap(s) => &s.edge_created_ms, + CsrStorage::Mmap(s) => s.edge_created_ms(), + } + } + /// Outgoing neighbor row indices for a CSR row. pub fn neighbors_out(&self, row: u32) -> &[u32] { match self { @@ -222,6 +231,32 @@ impl CsrStorage { } } + /// Like [`for_each_neighbor_edge`], additionally yielding the edge's + /// wall-clock creation stamp (Unix millis) for temporal-decay scoring. + /// Yields 0 (= unknown, decay-neutral) for segments without per-edge + /// stamps (version < 3 files). + #[inline] + pub fn for_each_neighbor_edge_ms(&self, row: u32, mut f: impl FnMut(u32, EdgeMeta, u64)) { + let r = row as usize; + let ro = self.row_offsets(); + let (start, end) = if r < self.node_count() as usize { + (ro[r] as usize, ro[r + 1] as usize) + } else { + (0, 0) + }; + let ci = self.col_indices(); + let em = self.edge_meta(); + let ecms = self.edge_created_ms(); + let validity = self.validity(); + for idx in start..end { + if validity.contains(idx as u32) { + // Checked access: ecms is empty for pre-v3 segments. + let ms = ecms.get(idx).copied().unwrap_or(0); + f(ci[idx], em[idx], ms); + } + } + } + /// Write the segment to a file (only supported for Heap variant). /// For Mmap variant, the file already exists on disk. pub fn write_to_file(&self, path: &Path) -> Result<(), CsrError> { diff --git a/src/graph/cypher/executor/eval.rs b/src/graph/cypher/executor/eval.rs index 22818bb26..996e76d92 100644 --- a/src/graph/cypher/executor/eval.rs +++ b/src/graph/cypher/executor/eval.rs @@ -18,6 +18,7 @@ pub(crate) fn eval_expr( params: &HashMap, immutable_segs: &[std::sync::Arc], snapshot_lsn: u64, + decay: Option, ) -> Value { match expr { Expr::Integer(n) => Value::Int(*n), @@ -31,7 +32,15 @@ pub(crate) fn eval_expr( Expr::Parameter(name) => params.get(name).cloned().unwrap_or(Value::Null), Expr::PropertyAccess { object, property } => { - let obj = eval_expr(object, row, memgraph, params, immutable_segs, snapshot_lsn); + let obj = eval_expr( + object, + row, + memgraph, + params, + immutable_segs, + snapshot_lsn, + decay, + ); match obj { Value::Node(key) => { if let Some(node) = memgraph.get_node(key) { @@ -70,13 +79,37 @@ pub(crate) fn eval_expr( } Expr::BinaryOp { left, op, right } => { - let lv = eval_expr(left, row, memgraph, params, immutable_segs, snapshot_lsn); - let rv = eval_expr(right, row, memgraph, params, immutable_segs, snapshot_lsn); + let lv = eval_expr( + left, + row, + memgraph, + params, + immutable_segs, + snapshot_lsn, + decay, + ); + let rv = eval_expr( + right, + row, + memgraph, + params, + immutable_segs, + snapshot_lsn, + decay, + ); eval_binary_op(&lv, *op, &rv) } Expr::Not(inner) => { - let v = eval_expr(inner, row, memgraph, params, immutable_segs, snapshot_lsn); + let v = eval_expr( + inner, + row, + memgraph, + params, + immutable_segs, + snapshot_lsn, + decay, + ); match v { Value::Bool(b) => Value::Bool(!b), Value::Null => Value::Null, @@ -85,7 +118,15 @@ pub(crate) fn eval_expr( } Expr::Negate(inner) => { - let v = eval_expr(inner, row, memgraph, params, immutable_segs, snapshot_lsn); + let v = eval_expr( + inner, + row, + memgraph, + params, + immutable_segs, + snapshot_lsn, + decay, + ); match v { Value::Int(n) => Value::Int(-n), Value::Float(f) => Value::Float(-f), @@ -94,14 +135,38 @@ pub(crate) fn eval_expr( } Expr::IsNull { expr, negated } => { - let v = eval_expr(expr, row, memgraph, params, immutable_segs, snapshot_lsn); + let v = eval_expr( + expr, + row, + memgraph, + params, + immutable_segs, + snapshot_lsn, + decay, + ); let is_null = matches!(v, Value::Null); Value::Bool(if *negated { !is_null } else { is_null }) } Expr::InList { expr, list } => { - let val = eval_expr(expr, row, memgraph, params, immutable_segs, snapshot_lsn); - let list_val = eval_expr(list, row, memgraph, params, immutable_segs, snapshot_lsn); + let val = eval_expr( + expr, + row, + memgraph, + params, + immutable_segs, + snapshot_lsn, + decay, + ); + let list_val = eval_expr( + list, + row, + memgraph, + params, + immutable_segs, + snapshot_lsn, + decay, + ); match list_val { Value::List(items) => { let found = items.iter().any(|item| { @@ -118,7 +183,15 @@ pub(crate) fn eval_expr( match lower_name.as_str() { "id" => { if let Some(arg) = args.first() { - let v = eval_expr(arg, row, memgraph, params, immutable_segs, snapshot_lsn); + let v = eval_expr( + arg, + row, + memgraph, + params, + immutable_segs, + snapshot_lsn, + decay, + ); match v { Value::Node(k) => Value::Int(k.data().as_ffi() as i64), Value::Edge(k) => Value::Int(k.data().as_ffi() as i64), @@ -130,7 +203,15 @@ pub(crate) fn eval_expr( } "labels" => { if let Some(arg) = args.first() { - let v = eval_expr(arg, row, memgraph, params, immutable_segs, snapshot_lsn); + let v = eval_expr( + arg, + row, + memgraph, + params, + immutable_segs, + snapshot_lsn, + decay, + ); if let Value::Node(k) = v { if let Some(node) = memgraph.get_node(k) { let labels: Vec = @@ -145,7 +226,15 @@ pub(crate) fn eval_expr( } "type" => { if let Some(arg) = args.first() { - let v = eval_expr(arg, row, memgraph, params, immutable_segs, snapshot_lsn); + let v = eval_expr( + arg, + row, + memgraph, + params, + immutable_segs, + snapshot_lsn, + decay, + ); if let Value::Edge(k) = v { if let Some(edge) = memgraph.get_edge(k) { return Value::Int(edge.edge_type as i64); @@ -158,7 +247,15 @@ pub(crate) fn eval_expr( } "size" => { if let Some(arg) = args.first() { - let v = eval_expr(arg, row, memgraph, params, immutable_segs, snapshot_lsn); + let v = eval_expr( + arg, + row, + memgraph, + params, + immutable_segs, + snapshot_lsn, + decay, + ); match v { Value::List(items) => Value::Int(items.len() as i64), Value::String(s) => Value::Int(s.len() as i64), @@ -170,7 +267,15 @@ pub(crate) fn eval_expr( } "tointeger" | "toint" => { if let Some(arg) = args.first() { - let v = eval_expr(arg, row, memgraph, params, immutable_segs, snapshot_lsn); + let v = eval_expr( + arg, + row, + memgraph, + params, + immutable_segs, + snapshot_lsn, + decay, + ); match v { Value::Int(n) => Value::Int(n), Value::Float(f) => Value::Int(f as i64), @@ -183,7 +288,15 @@ pub(crate) fn eval_expr( } "tofloat" => { if let Some(arg) = args.first() { - let v = eval_expr(arg, row, memgraph, params, immutable_segs, snapshot_lsn); + let v = eval_expr( + arg, + row, + memgraph, + params, + immutable_segs, + snapshot_lsn, + decay, + ); match v { Value::Float(f) => Value::Float(f), Value::Int(n) => Value::Float(n as f64), @@ -196,7 +309,15 @@ pub(crate) fn eval_expr( } "tostring" => { if let Some(arg) = args.first() { - let v = eval_expr(arg, row, memgraph, params, immutable_segs, snapshot_lsn); + let v = eval_expr( + arg, + row, + memgraph, + params, + immutable_segs, + snapshot_lsn, + decay, + ); Value::String(value_to_string(&v)) } else { Value::Null @@ -207,7 +328,15 @@ pub(crate) fn eval_expr( // the Project phase as a future enhancement. For now, // return the value or null for count. if let Some(arg) = args.first() { - eval_expr(arg, row, memgraph, params, immutable_segs, snapshot_lsn) + eval_expr( + arg, + row, + memgraph, + params, + immutable_segs, + snapshot_lsn, + decay, + ) } else { Value::Null } @@ -217,7 +346,15 @@ pub(crate) fn eval_expr( // Enables Lunaris GraphFirstRetriever edge-property temporal // filter: `coalesce(r.valid_to, 9999999999) >= asof`. for arg in args { - let v = eval_expr(arg, row, memgraph, params, immutable_segs, snapshot_lsn); + let v = eval_expr( + arg, + row, + memgraph, + params, + immutable_segs, + snapshot_lsn, + decay, + ); if !matches!(v, Value::Null) { return v; } @@ -231,7 +368,17 @@ pub(crate) fn eval_expr( Expr::List(items) => { let values: Vec = items .iter() - .map(|item| eval_expr(item, row, memgraph, params, immutable_segs, snapshot_lsn)) + .map(|item| { + eval_expr( + item, + row, + memgraph, + params, + immutable_segs, + snapshot_lsn, + decay, + ) + }) .collect(); Value::List(values) } @@ -242,7 +389,15 @@ pub(crate) fn eval_expr( .map(|(k, v)| { ( k.clone(), - eval_expr(v, row, memgraph, params, immutable_segs, snapshot_lsn), + eval_expr( + v, + row, + memgraph, + params, + immutable_segs, + snapshot_lsn, + decay, + ), ) }) .collect(); @@ -280,6 +435,7 @@ pub(crate) fn eval_expr( memgraph, immutable_segs, snapshot_lsn, + decay, src_key, dst_key, edge_types, diff --git a/src/graph/cypher/executor/mod.rs b/src/graph/cypher/executor/mod.rs index 0c7b4d112..e2656dd71 100644 --- a/src/graph/cypher/executor/mod.rs +++ b/src/graph/cypher/executor/mod.rs @@ -181,6 +181,10 @@ pub struct ExecutionContext { /// Valid-time filter: only entities with valid_from <= T <= valid_to. /// None = no valid-time filter (current behavior). pub valid_time_as_of: Option, + /// Temporal-decay scoring for traversal cost (agent-memory recency), + /// parsed from `GRAPH.QUERY ... --decay `. + /// None = distance-only shortest paths (current behavior). + pub decay: Option, } // --------------------------------------------------------------------------- diff --git a/src/graph/cypher/executor/read.rs b/src/graph/cypher/executor/read.rs index de94619a2..bc7994050 100644 --- a/src/graph/cypher/executor/read.rs +++ b/src/graph/cypher/executor/read.rs @@ -179,7 +179,15 @@ pub fn execute( PhysicalOp::Filter { expr } => { rows.retain(|row| { matches!( - eval_expr(expr, row, memgraph, params, csr_segs, ctx.snapshot_lsn), + eval_expr( + expr, + row, + memgraph, + params, + csr_segs, + ctx.snapshot_lsn, + ctx.decay + ), Value::Bool(true) ) }); @@ -215,6 +223,7 @@ pub fn execute( params, csr_segs, ctx.snapshot_lsn, + ctx.decay, ) } }) @@ -263,10 +272,24 @@ pub fn execute( } else { rows.sort_by(|a, b| { for (expr, ascending) in items { - let va = - eval_expr(expr, a, memgraph, params, csr_segs, ctx.snapshot_lsn); - let vb = - eval_expr(expr, b, memgraph, params, csr_segs, ctx.snapshot_lsn); + let va = eval_expr( + expr, + a, + memgraph, + params, + csr_segs, + ctx.snapshot_lsn, + ctx.decay, + ); + let vb = eval_expr( + expr, + b, + memgraph, + params, + csr_segs, + ctx.snapshot_lsn, + ctx.decay, + ); let ord = compare_values(&va, &vb); let ord = if *ascending { ord } else { ord.reverse() }; if ord != std::cmp::Ordering::Equal { @@ -286,6 +309,7 @@ pub fn execute( params, csr_segs, ctx.snapshot_lsn, + ctx.decay, ) { Value::Int(n) if n >= 0 => n as usize, _ => 0, @@ -305,6 +329,7 @@ pub fn execute( params, csr_segs, ctx.snapshot_lsn, + ctx.decay, ) { Value::Int(n) if n >= 0 => n as usize, _ => 0, @@ -325,7 +350,15 @@ pub fn execute( PhysicalOp::Unwind { expr, alias } => { let mut new_rows = Vec::new(); for row in &rows { - let val = eval_expr(expr, row, memgraph, params, csr_segs, ctx.snapshot_lsn); + let val = eval_expr( + expr, + row, + memgraph, + params, + csr_segs, + ctx.snapshot_lsn, + ctx.decay, + ); if let Value::List(items) = val { for item in items { let mut new_row = row.clone(); @@ -405,6 +438,7 @@ pub fn execute( memgraph, csr_segs, ctx.snapshot_lsn, + ctx.decay, src_key, dst_key, edge_types, @@ -650,7 +684,15 @@ pub fn execute_profile( PhysicalOp::Filter { expr } => { rows.retain(|row| { matches!( - eval_expr(expr, row, memgraph, params, csr_segs, ctx.snapshot_lsn), + eval_expr( + expr, + row, + memgraph, + params, + csr_segs, + ctx.snapshot_lsn, + ctx.decay + ), Value::Bool(true) ) }); @@ -686,6 +728,7 @@ pub fn execute_profile( params, csr_segs, ctx.snapshot_lsn, + ctx.decay, ) } }) @@ -732,10 +775,24 @@ pub fn execute_profile( } else { rows.sort_by(|a, b| { for (expr, ascending) in items { - let va = - eval_expr(expr, a, memgraph, params, csr_segs, ctx.snapshot_lsn); - let vb = - eval_expr(expr, b, memgraph, params, csr_segs, ctx.snapshot_lsn); + let va = eval_expr( + expr, + a, + memgraph, + params, + csr_segs, + ctx.snapshot_lsn, + ctx.decay, + ); + let vb = eval_expr( + expr, + b, + memgraph, + params, + csr_segs, + ctx.snapshot_lsn, + ctx.decay, + ); let ord = compare_values(&va, &vb); let ord = if *ascending { ord } else { ord.reverse() }; if ord != std::cmp::Ordering::Equal { @@ -755,6 +812,7 @@ pub fn execute_profile( params, csr_segs, ctx.snapshot_lsn, + ctx.decay, ) { Value::Int(n) if n >= 0 => n as usize, _ => 0, @@ -774,6 +832,7 @@ pub fn execute_profile( params, csr_segs, ctx.snapshot_lsn, + ctx.decay, ) { Value::Int(n) if n >= 0 => n as usize, _ => 0, @@ -794,7 +853,15 @@ pub fn execute_profile( PhysicalOp::Unwind { expr, alias } => { let mut new_rows = Vec::new(); for row in &rows { - let val = eval_expr(expr, row, memgraph, params, csr_segs, ctx.snapshot_lsn); + let val = eval_expr( + expr, + row, + memgraph, + params, + csr_segs, + ctx.snapshot_lsn, + ctx.decay, + ); if let Value::List(items) = val { for item in items { let mut new_row = row.clone(); @@ -875,6 +942,7 @@ pub fn execute_profile( memgraph, csr_segs, ctx.snapshot_lsn, + ctx.decay, src_key, dst_key, edge_types, diff --git a/src/graph/cypher/executor/shortest_path.rs b/src/graph/cypher/executor/shortest_path.rs index 259548788..3afb9f15a 100644 --- a/src/graph/cypher/executor/shortest_path.rs +++ b/src/graph/cypher/executor/shortest_path.rs @@ -23,6 +23,7 @@ pub(crate) fn run_shortest_path( memgraph: &MemGraph, immutable_segs: &[Arc], snapshot_lsn: u64, + decay: Option, src_key: NodeKey, dst_key: NodeKey, edge_types: &[String], @@ -60,7 +61,13 @@ pub(crate) fn run_shortest_path( edge_type_filter, ); - let cost_fn = crate::graph::scoring::WeightedCostFn::new(0.0, 1.0, 0); + // Decay off (None): distance-only Dijkstra — the time term is zero and + // edge ages are never read, preserving the exact pre-decay behavior. + // Decay on: cost = lambda*time_weight*age_sec + |weight| (see DecayConfig). + let cost_fn = match decay { + Some(d) => d.cost_fn(), + None => crate::graph::scoring::WeightedCostFn::new(0.0, 1.0, 0), + }; let capped_hops = max_hops.min(MAX_HOPS_CAP); let dijkstra = DijkstraTraversal::new(cost_fn, capped_hops); diff --git a/src/graph/cypher/executor/write.rs b/src/graph/cypher/executor/write.rs index daa553c5e..311e6f6ba 100644 --- a/src/graph/cypher/executor/write.rs +++ b/src/graph/cypher/executor/write.rs @@ -146,7 +146,7 @@ pub fn execute_mut( PhysicalOp::Filter { expr } => { rows.retain(|row| { matches!( - eval_expr(expr, row, &graph.write_buf, params, &[], 0), + eval_expr(expr, row, &graph.write_buf, params, &[], 0, None), Value::Bool(true) ) }); @@ -175,7 +175,15 @@ pub fn execute_mut( row.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); Value::Map(entries) } else { - eval_expr(&item.expr, row, &graph.write_buf, params, &[], 0) + eval_expr( + &item.expr, + row, + &graph.write_buf, + params, + &[], + 0, + None, + ) } }) .collect() @@ -221,8 +229,8 @@ pub fn execute_mut( } else { rows.sort_by(|a, b| { for (expr, ascending) in items { - let va = eval_expr(expr, a, &graph.write_buf, params, &[], 0); - let vb = eval_expr(expr, b, &graph.write_buf, params, &[], 0); + let va = eval_expr(expr, a, &graph.write_buf, params, &[], 0, None); + let vb = eval_expr(expr, b, &graph.write_buf, params, &[], 0, None); let ord = compare_values(&va, &vb); let ord = if *ascending { ord } else { ord.reverse() }; if ord != std::cmp::Ordering::Equal { @@ -235,7 +243,15 @@ pub fn execute_mut( } PhysicalOp::Limit { count } => { - let n = match eval_expr(count, &HashMap::new(), &graph.write_buf, params, &[], 0) { + let n = match eval_expr( + count, + &HashMap::new(), + &graph.write_buf, + params, + &[], + 0, + None, + ) { Value::Int(n) if n >= 0 => n as usize, _ => 0, }; @@ -247,7 +263,15 @@ pub fn execute_mut( } PhysicalOp::Skip { count } => { - let n = match eval_expr(count, &HashMap::new(), &graph.write_buf, params, &[], 0) { + let n = match eval_expr( + count, + &HashMap::new(), + &graph.write_buf, + params, + &[], + 0, + None, + ) { Value::Int(n) if n >= 0 => n as usize, _ => 0, }; @@ -267,7 +291,7 @@ pub fn execute_mut( PhysicalOp::Unwind { expr, alias } => { let mut new_rows = Vec::new(); for row in &rows { - let val = eval_expr(expr, row, &graph.write_buf, params, &[], 0); + let val = eval_expr(expr, row, &graph.write_buf, params, &[], 0, None); if let Value::List(items) = val { for item in items { let mut new_row = row.clone(); @@ -296,8 +320,15 @@ pub fn execute_mut( .properties .iter() .filter_map(|(name, expr)| { - let val = - eval_expr(expr, &new_row, &graph.write_buf, params, &[], 0); + let val = eval_expr( + expr, + &new_row, + &graph.write_buf, + params, + &[], + 0, + None, + ); value_to_property_value(&val) .map(|pv| (label_to_id(name.as_bytes()), pv)) }) @@ -362,8 +393,15 @@ pub fn execute_mut( value, } => { if let Some(Value::Node(nk)) = row.get(variable) { - let val = - eval_expr(value, row, &graph.write_buf, params, &[], 0); + let val = eval_expr( + value, + row, + &graph.write_buf, + params, + &[], + 0, + None, + ); if let Some(pv) = value_to_property_value(&val) { let pid = label_to_id(property.as_bytes()); if let Some(node) = graph.write_buf.get_node_mut(*nk) { @@ -417,7 +455,7 @@ pub fn execute_mut( let _ = detach; // Detach is always implied for MemGraph soft-delete. for row in &rows { for expr in exprs { - let val = eval_expr(expr, row, &graph.write_buf, params, &[], 0); + let val = eval_expr(expr, row, &graph.write_buf, params, &[], 0, None); match val { Value::Node(nk) => { // Phase 174 FIX-01: snapshot node state BEFORE @@ -482,8 +520,15 @@ pub fn execute_mut( .properties .iter() .filter_map(|(name, expr)| { - let val = - eval_expr(expr, &new_row, &graph.write_buf, params, &[], 0); + let val = eval_expr( + expr, + &new_row, + &graph.write_buf, + params, + &[], + 0, + None, + ); value_to_property_value(&val) .map(|pv| (label_to_id(name.as_bytes()), pv)) }) @@ -656,6 +701,7 @@ pub fn execute_mut( params, &[], 0, + None, ); value_to_property_value(&val) .map(|pv| (label_to_id(name.as_bytes()), pv)) @@ -692,6 +738,7 @@ pub fn execute_mut( params, &[], 0, + None, ); value_to_property_value(&val) .map(|pv| (label_to_id(name.as_bytes()), pv)) @@ -826,7 +873,7 @@ pub(crate) fn apply_set_items( value, } => { if let Some(Value::Node(nk)) = row.get(variable) { - let val = eval_expr(value, row, memgraph, params, &[], 0); + let val = eval_expr(value, row, memgraph, params, &[], 0, None); if let Some(pv) = value_to_property_value(&val) { let pid = label_to_id(property.as_bytes()); if let Some(node) = memgraph.get_node_mut(*nk) { @@ -900,7 +947,7 @@ pub(crate) fn resolve_or_find_node( .properties .iter() .filter_map(|(name, expr)| { - let val = eval_expr(expr, row, memgraph, params, &[], 0); + let val = eval_expr(expr, row, memgraph, params, &[], 0, None); value_to_property_value(&val).map(|pv| (label_to_id(name.as_bytes()), pv)) }) .collect(); diff --git a/src/graph/memgraph.rs b/src/graph/memgraph.rs index 8fbc088cc..fdcb7261a 100644 --- a/src/graph/memgraph.rs +++ b/src/graph/memgraph.rs @@ -117,6 +117,9 @@ impl MemGraph { txn_id: 0, valid_from: 0, valid_to: i64::MAX, + // Shard-cached clock read (1ms tick) -- no syscall on the insert + // path. Powers temporal-decay traversal scoring. + created_ms: crate::storage::entry::current_time_ms(), }); // Push edge key into src.outgoing and dst.incoming. @@ -402,6 +405,23 @@ mod tests { assert_eq!(node.deleted_lsn, u64::MAX); } + use crate::storage::entry::ClockPin; + + #[test] + fn test_add_edge_stamps_created_ms_from_cached_clock() { + let _pin = ClockPin::set(5, 5_000); + let mut g = MemGraph::new(1000); + let a = g.add_node(smallvec![0], empty_props(), None, 1); + let b = g.add_node(smallvec![0], empty_props(), None, 1); + let ek = g.add_edge(a, b, 1, 1.0, None, 2).expect("edge ok"); + + let edge = g.get_edge(ek).expect("edge should exist"); + assert_eq!( + edge.created_ms, 5_000, + "add_edge must stamp created_ms from the shard-cached clock" + ); + } + #[test] fn test_insert_edge_and_adjacency() { let mut g = MemGraph::new(1000); diff --git a/src/graph/scoring.rs b/src/graph/scoring.rs index 18f67de70..ece04274b 100644 --- a/src/graph/scoring.rs +++ b/src/graph/scoring.rs @@ -152,6 +152,65 @@ impl WeightedCostFn { let age = self.now.saturating_sub(timestamp) as f64; self.time_weight * age + self.distance_weight * weight.abs() } + + /// Wall-clock variant: `created_ms` and `self.now` are Unix millis, the + /// age term is converted to SECONDS so `time_weight` reads as cost per + /// second of edge age (matching the user-facing `--decay ` + /// knob). + /// + /// `created_ms == 0` means "unknown" (pre-upgrade edge, or CSR segment + /// without per-edge stamps): the time term is dropped entirely — neutral, + /// never treated as maximally old. A stamp ahead of `now` (clock skew) + /// saturates to age 0. + #[inline] + pub fn cost_ms(&self, created_ms: u64, weight: f64) -> f64 { + let distance_cost = self.distance_weight * weight.abs(); + if created_ms == 0 { + return distance_cost; + } + let age_sec = self.now.saturating_sub(created_ms) as f64 / 1000.0; + self.time_weight * age_sec + distance_cost + } +} + +// --------------------------------------------------------------------------- +// Decay configuration (user-facing knob) +// --------------------------------------------------------------------------- + +/// Per-query temporal-decay configuration, parsed from the command surface +/// (`GRAPH.QUERY ... --decay [--time-weight ]`, +/// `FT.NAVIGATE ... DECAY `). +/// +/// Rides `ExecutionContext` into the traversal layer; `None` keeps the +/// exact pre-decay code path (zero cost when off). +#[derive(Debug, Clone, Copy)] +pub struct DecayConfig { + /// Decay rate in 1/seconds. The effective time cost per edge is + /// `lambda_per_sec * time_weight * age_seconds`. + pub lambda_per_sec: f64, + /// Multiplier on the decay term (default 1.0). + pub time_weight: f64, + /// Query-start wall clock (Unix millis) from the shard-cached clock. + pub now_ms: u64, +} + +impl DecayConfig { + /// Build the traversal cost function for this decay setting. + /// Distance weight stays 1.0 — decay biases, it does not replace + /// the weight-based shortest path semantics. + pub fn cost_fn(&self) -> WeightedCostFn { + WeightedCostFn::new(self.lambda_per_sec * self.time_weight, 1.0, self.now_ms) + } + + /// Pure age penalty `lambda * time_weight * age_seconds` for an edge + /// created at `created_ms` (Unix millis). Single home for the decay + /// formula so re-ranking surfaces (FT.NAVIGATE) cannot drift from the + /// traversal cost: unknown stamps (0) are neutral and future stamps + /// saturate to age 0, exactly per [`WeightedCostFn::cost_ms`]. + #[inline] + pub fn age_penalty_ms(&self, created_ms: u64) -> f64 { + self.cost_fn().cost_ms(created_ms, 0.0) + } } #[cfg(test)] @@ -167,6 +226,32 @@ mod tests { KeyData::from_ffi(1).into() } + // --- WeightedCostFn::cost_ms tests (wall-clock decay) --- + + #[test] + fn test_cost_ms_converts_millis_to_seconds() { + // now = 10s (10_000 ms); edge created at 4s -> age 6s. + // cost = time_weight * age_sec + distance_weight * |weight| + // = 0.5 * 6.0 + 1.0 * 2.0 = 5.0 + let f = WeightedCostFn::new(0.5, 1.0, 10_000); + assert!((f.cost_ms(4_000, 2.0) - 5.0).abs() < 1e-9); + } + + #[test] + fn test_cost_ms_zero_created_ms_is_neutral() { + // created_ms == 0 means "unknown" (pre-upgrade edge or CSR segment + // without per-edge stamps): no age penalty, distance cost only. + let f = WeightedCostFn::new(0.5, 1.0, 10_000); + assert!((f.cost_ms(0, 2.0) - 2.0).abs() < 1e-9); + } + + #[test] + fn test_cost_ms_future_stamp_saturates() { + // A stamp ahead of `now` (clock skew) must not underflow: age = 0. + let f = WeightedCostFn::new(0.5, 1.0, 10_000); + assert!((f.cost_ms(20_000, 2.0) - 2.0).abs() < 1e-9); + } + // --- TemporalDecayScorer tests --- #[test] @@ -315,6 +400,22 @@ mod tests { assert!((cost - 70.0).abs() < f64::EPSILON); } + #[test] + fn test_decay_age_penalty_ms_matches_cost_ms() { + let d = DecayConfig { + lambda_per_sec: 0.5, + time_weight: 2.0, + now_ms: 100_000, + }; + // age = (100_000 - 40_000) / 1000 = 60s; penalty = 0.5 * 2.0 * 60 = 60 + assert!((d.age_penalty_ms(40_000) - 60.0).abs() < f64::EPSILON); + // identical to the traversal cost with zero weight — one formula + assert_eq!(d.age_penalty_ms(40_000), d.cost_fn().cost_ms(40_000, 0.0)); + // unknown stamp is neutral, future stamp saturates to 0 + assert_eq!(d.age_penalty_ms(0), 0.0); + assert_eq!(d.age_penalty_ms(200_000), 0.0); + } + // --- EdgeScore tests --- #[test] diff --git a/src/graph/traversal.rs b/src/graph/traversal.rs index afc4d2279..f30e0d648 100644 --- a/src/graph/traversal.rs +++ b/src/graph/traversal.rs @@ -76,6 +76,10 @@ pub struct MergedNeighbor { pub weight: f64, /// Timestamp (created_lsn) of the edge. pub timestamp: u64, + /// Wall-clock creation stamp (Unix millis) of the edge. 0 = unknown + /// (pre-upgrade data or CSR segments without per-edge stamps); decay + /// scoring treats 0 as neutral (no age penalty). + pub created_ms: u64, } // --------------------------------------------------------------------------- @@ -164,6 +168,7 @@ impl<'a> SegmentMergeReader<'a> { edge_type: edge.edge_type, weight: edge.weight, timestamp: edge.created_lsn, + created_ms: edge.created_ms, }); } } @@ -192,7 +197,7 @@ impl<'a> SegmentMergeReader<'a> { let node_meta = csr.node_meta(); let csr_lsn = csr.created_lsn(); - csr.for_each_neighbor_edge(row, |col_idx, meta| { + csr.for_each_neighbor_edge_ms(row, |col_idx, meta, created_ms| { // Apply edge type filter. if let Some(filter) = edge_type_filter { if meta.edge_type != filter { @@ -219,6 +224,10 @@ impl<'a> SegmentMergeReader<'a> { edge_type: meta.edge_type, weight: 1.0, timestamp: csr_lsn, + // Real per-edge wall-clock stamp from the version + // >= 3 segment format; 0 = unknown (pre-v3 file), + // which decay treats as neutral. + created_ms, }); } } @@ -565,7 +574,7 @@ impl BoundedDfs { if visited_set.contains(&neighbor.node) { continue; } - let edge_cost = self.cost_fn.cost(neighbor.timestamp, neighbor.weight); + let edge_cost = self.cost_fn.cost_ms(neighbor.created_ms, neighbor.weight); let new_cost = cum_cost + edge_cost; // Max-cost pruning (TRAV-02). @@ -700,7 +709,7 @@ impl DijkstraTraversal { reader.neighbors_into(node, &mut nb_seen, &mut nb_buf); for &neighbor in &nb_buf { - let edge_cost = self.cost_fn.cost(neighbor.timestamp, neighbor.weight); + let edge_cost = self.cost_fn.cost_ms(neighbor.created_ms, neighbor.weight); let new_cost = cost + edge_cost; let is_better = match dist.get(&neighbor.node) { @@ -762,6 +771,91 @@ mod tests { // --- SegmentMergeReader tests --- + #[test] + fn test_dijkstra_decay_prefers_fresh_path() { + // Topology: direct a->c (weight 1.0, STALE, created at t=1s) vs + // detour a->b->c (weight 0.6 each, FRESH, created at t=99s). + // + // Decay OFF (time_weight = 0): direct wins (1.0 < 1.2). + // Decay ON (time_weight = 0.01/sec, now = 100s): + // direct: 1.0 + 0.01 * 99s = 1.99 + // detour: 1.2 + 0.01 * (1s + 1s) = 1.22 -> detour wins. + let mut mg = MemGraph::new(1000); + let a = mg.add_node(smallvec![0], empty_props(), None, 1); + let b = mg.add_node(smallvec![0], empty_props(), None, 1); + let c = mg.add_node(smallvec![0], empty_props(), None, 1); + + { + let _pin = crate::storage::entry::ClockPin::set(1, 1_000); + mg.add_edge(a, c, 1, 1.0, None, 2).expect("stale direct"); + } + { + let _pin = crate::storage::entry::ClockPin::set(99, 99_000); + mg.add_edge(a, b, 1, 0.6, None, 3).expect("fresh hop 1"); + mg.add_edge(b, c, 1, 0.6, None, 4).expect("fresh hop 2"); + } + + let csr_segs: Vec> = vec![]; + let reader = SegmentMergeReader::new( + Some(&mg), + &csr_segs, + Direction::Outgoing, + u64::MAX - 1, + None, + ); + + // Decay off: direct path. + let off = DijkstraTraversal::new(WeightedCostFn::new(0.0, 1.0, 0), 10) + .shortest_path(&reader, a, c) + .expect("ok") + .expect("path"); + assert_eq!( + off.path, + vec![a, c], + "decay off must keep distance-only behavior" + ); + + // Decay on: fresh detour wins. + let on = DijkstraTraversal::new(WeightedCostFn::new(0.01, 1.0, 100_000), 10) + .shortest_path(&reader, a, c) + .expect("ok") + .expect("path"); + assert_eq!( + on.path, + vec![a, b, c], + "decay must steer toward fresh edges" + ); + } + + #[test] + fn test_merge_reader_propagates_created_ms() { + // MemGraph edges must surface their wall-clock creation stamp through + // MergedNeighbor so decay-aware cost functions can age them. + let mut mg = MemGraph::new(1000); + let a = mg.add_node(smallvec![0], empty_props(), None, 1); + let b = mg.add_node(smallvec![0], empty_props(), None, 1); + { + let _pin = crate::storage::entry::ClockPin::set(7, 7_000); + mg.add_edge(a, b, 1, 2.0, None, 2).expect("ok"); + } + + let csr_segs: Vec> = vec![]; + let reader = SegmentMergeReader::new( + Some(&mg), + &csr_segs, + Direction::Outgoing, + u64::MAX - 1, + None, + ); + + let neighbors = reader.neighbors(a); + assert_eq!(neighbors.len(), 1); + assert_eq!( + neighbors[0].created_ms, 7_000, + "MergedNeighbor must carry the edge's created_ms" + ); + } + #[test] fn test_merge_reader_memgraph_only() { let mut mg = MemGraph::new(1000); @@ -1181,22 +1275,33 @@ mod tests { #[test] fn test_dijkstra_composite_cost() { - // TRAV-03/04: time_weight * (now - ts) + distance_weight * distance + // TRAV-03/04: time_weight * age_seconds + distance_weight * distance. + // Edge ages come from wall-clock created_ms (pinned cached clock); + // cost_ms converts ms -> seconds. let mut mg = MemGraph::new(1000); let a = mg.add_node(smallvec![0], empty_props(), None, 1); let b = mg.add_node(smallvec![0], empty_props(), None, 1); let c = mg.add_node(smallvec![0], empty_props(), None, 1); - // Route 1: a->c, weight=2.0, timestamp=90 (age=10) + // Route 1: a->c, weight=2.0, created at t=90s (age=10s at now=100s) // cost = 0.5*10 + 1.0*2.0 = 7.0 - mg.add_edge(a, c, 1, 2.0, None, 90).expect("ok"); + { + let _pin = crate::storage::entry::ClockPin::set(90, 90_000); + mg.add_edge(a, c, 1, 2.0, None, 90).expect("ok"); + } - // Route 2: a->b (weight=1.0, ts=95, age=5) -> b->c (weight=1.0, ts=80, age=20) + // Route 2: a->b (weight=1.0, t=95s, age=5s) -> b->c (weight=1.0, t=80s, age=20s) // a->b cost = 0.5*5 + 1.0*1.0 = 3.5 // b->c cost = 0.5*20 + 1.0*1.0 = 11.0 // total = 14.5 - mg.add_edge(a, b, 1, 1.0, None, 95).expect("ok"); - mg.add_edge(b, c, 1, 1.0, None, 80).expect("ok"); + { + let _pin = crate::storage::entry::ClockPin::set(95, 95_000); + mg.add_edge(a, b, 1, 1.0, None, 95).expect("ok"); + } + { + let _pin = crate::storage::entry::ClockPin::set(80, 80_000); + mg.add_edge(b, c, 1, 1.0, None, 80).expect("ok"); + } let csr_segs: Vec> = vec![]; let reader = SegmentMergeReader::new( @@ -1207,7 +1312,7 @@ mod tests { None, ); - let cost_fn = WeightedCostFn::new(0.5, 1.0, 100); + let cost_fn = WeightedCostFn::new(0.5, 1.0, 100_000); let dijkstra = DijkstraTraversal::new(cost_fn, 10); let result = dijkstra .shortest_path(&reader, a, c) @@ -1309,6 +1414,33 @@ mod tests { assert!(!neighbors.is_empty()); } + #[test] + fn test_csr_neighbors_surface_edge_created_ms() { + // Edges frozen into a CSR segment keep their wall-clock stamps (v3 + // format) — the merge reader must surface them on MergedNeighbor so + // decay scoring sees true edge age after compaction. + let mut mg = MemGraph::new(1000); + let a = mg.add_node(smallvec![0], empty_props(), None, 1); + let b = mg.add_node(smallvec![0], empty_props(), None, 1); + { + let _pin = crate::storage::entry::ClockPin::set(42, 42_000); + mg.add_edge(a, b, 1, 1.0, None, 2).expect("ok"); + } + + let frozen = mg.freeze().expect("ok"); + let csr = CsrSegment::from_frozen(frozen, 5).expect("ok"); + let csr_segs = vec![Arc::new(CsrStorage::from(csr))]; + let reader: SegmentMergeReader<'_> = + SegmentMergeReader::new(None, &csr_segs, Direction::Outgoing, u64::MAX - 1, None); + + let neighbors = reader.neighbors(a); + assert_eq!(neighbors.len(), 1); + assert_eq!( + neighbors[0].created_ms, 42_000, + "CSR neighbor must carry the persisted per-edge stamp" + ); + } + // --- ParallelBfs tests --- #[test] diff --git a/src/graph/types.rs b/src/graph/types.rs index e250844b7..b4bad4917 100644 --- a/src/graph/types.rs +++ b/src/graph/types.rs @@ -84,8 +84,22 @@ pub struct MutableEdge { pub valid_from: i64, /// Valid-time end (Unix millis). i64::MAX = still valid / open-ended (default). pub valid_to: i64, + /// Wall-clock creation stamp (Unix millis) from the shard-cached clock. + /// Used by temporal-decay traversal scoring (agent-memory recency). + /// 0 = unknown (e.g. pre-upgrade data); decay treats unknown as neutral. + /// Distinct from `valid_from`, which is user-owned bi-temporal valid-time. + pub created_ms: u64, } +/// Current CSR segment format version, stamped on every newly built segment +/// (`CsrSegment::from_frozen`, `compact_segments`). Single source of truth — +/// a construction site stamping a stale literal is exactly the bug class that +/// made vacuumed segments misparse on reload (v1 stamp on v2 records). +/// +/// Parse-side gates stay numeric (`version >= 2`, `version >= 3`): they encode +/// the version a feature was INTRODUCED at and must not track this constant. +pub const CSR_CURRENT_VERSION: u32 = 3; + /// On-disk CSR segment header -- cache-line aligned, zero-copy mmap. #[derive(Debug)] #[repr(C, align(64))] @@ -103,10 +117,14 @@ pub struct GraphSegmentHeader { pub validity_bitmap_offset: u64, pub created_lsn: u64, pub checksum: u64, + /// Byte offset of the per-edge `created_ms` array (version >= 3). + /// 0 for version 1/2 segments (section absent). Informational — parsers + /// compute section positions positionally and gate on `version`. + pub edge_created_ms_offset: u64, } -// 4 + 4 + 4 + 4 + 8 + 8 + 8 + 8 + 8 + 8 + 8 + 8 = 80 bytes, padded to 128 with align(64) -// Actually: fields sum to 80 bytes. With align(64) the struct is padded to 128. +// Fields sum to 88 bytes (was 80 before edge_created_ms_offset). With +// align(64) the struct is padded to 128 — the on-disk size is unchanged. const _: () = assert!(core::mem::size_of::() == 128); const _: () = assert!(core::mem::align_of::() == 64); diff --git a/src/graph/visibility.rs b/src/graph/visibility.rs index 3f07e09c6..673a7d634 100644 --- a/src/graph/visibility.rs +++ b/src/graph/visibility.rs @@ -182,6 +182,7 @@ mod tests { txn_id, valid_from: 0, valid_to: i64::MAX, + created_ms: 0, } } diff --git a/src/storage/entry.rs b/src/storage/entry.rs index 069502ba7..63757213f 100644 --- a/src/storage/entry.rs +++ b/src/storage/entry.rs @@ -34,6 +34,28 @@ pub fn tl_clock_set(secs: u32, ms: u64) { TL_NOW_MS.with(|c| c.set(ms)); } +/// RAII pin for the thread-local cached clock in tests; resets to 0 on drop +/// (panic-safe), restoring the syscall fallback. Single shared home so test +/// modules don't each carry their own copy or leak a pinned clock into the +/// next test on the same thread. +#[cfg(test)] +pub struct ClockPin; + +#[cfg(test)] +impl ClockPin { + pub fn set(secs: u32, ms: u64) -> Self { + tl_clock_set(secs, ms); + ClockPin + } +} + +#[cfg(test)] +impl Drop for ClockPin { + fn drop(&mut self) { + tl_clock_set(0, 0); + } +} + #[cold] fn current_secs_syscall() -> u32 { SystemTime::now()