Initial commit: aether-atom lattice library
This commit is contained in:
@@ -0,0 +1 @@
|
||||
target/
|
||||
Generated
+332
@@ -0,0 +1,332 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "aether-atom"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha3",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "keccak"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
|
||||
dependencies = [
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha3"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "aether-atom"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "The atomic primitive of aether-tools: canonicalize, record, ask."
|
||||
|
||||
[lib]
|
||||
name = "aether_atom"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
sha3 = "0.10"
|
||||
once_cell = "1"
|
||||
regex = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,46 @@
|
||||
use aether_atom::*;
|
||||
use aether_atom::fingerprint;
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut lat = Lattice::open(dir.path()).unwrap();
|
||||
let body: Vec<u8> = vec![0xAB; 1024];
|
||||
let body_str = BodyAsStr(&body);
|
||||
|
||||
remember(&mut lat, "global", &body_str, body.clone(), Some(true));
|
||||
let _ = ask(&mut lat, "global", &body_str, |_| body.clone());
|
||||
|
||||
let n = 10_000;
|
||||
|
||||
// 1) cached ask() ops — these include a per-call disk write
|
||||
let t = Instant::now();
|
||||
for _ in 0..n {
|
||||
let _ = ask(&mut lat, "global", &body_str, |_| Vec::new());
|
||||
}
|
||||
let cached = t.elapsed();
|
||||
println!(
|
||||
"{n} cached ask() ops (incl. disk write): {cached:?} ({:.2} µs/op)",
|
||||
cached.as_micros() as f64 / n as f64
|
||||
);
|
||||
|
||||
let n_lookups = 100_000;
|
||||
let sig = fingerprint(&body);
|
||||
// 2) raw lookup() — also writes per call
|
||||
let t = Instant::now();
|
||||
for _ in 0..n_lookups {
|
||||
let _ = lat.lookup(&sig, "global");
|
||||
}
|
||||
println!(
|
||||
"{n_lookups} raw lookup() ops: {:?} ({:.2} µs/op)",
|
||||
t.elapsed(),
|
||||
t.elapsed().as_micros() as f64 / n_lookups as f64
|
||||
);
|
||||
}
|
||||
|
||||
struct BodyAsStr<'a>(&'a [u8]);
|
||||
impl Canonicalize for BodyAsStr<'_> {
|
||||
fn canonical(&self) -> Vec<u8> {
|
||||
self.0.to_vec()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Canonicalization: collapse equivalent inputs to one form.
|
||||
//!
|
||||
//! The atom's contract is that `canonicalize(x) == canonicalize(y)` whenever
|
||||
//! `x` and `y` are equivalent for our purposes. The user provides the
|
||||
//! equivalence; the atom provides the hash.
|
||||
|
||||
/// Anything that can be canonicalized to a stable byte sequence.
|
||||
///
|
||||
/// Two inputs are "the same" if their canonical forms are byte-equal.
|
||||
/// The default implementation for `&str`/`String` is identity; users
|
||||
/// can implement observably-equivalent reductions (e.g., stripping paths).
|
||||
pub trait Canonicalize {
|
||||
fn canonical(&self) -> Vec<u8>;
|
||||
}
|
||||
|
||||
impl Canonicalize for str {
|
||||
fn canonical(&self) -> Vec<u8> {
|
||||
self.as_bytes().to_vec()
|
||||
}
|
||||
}
|
||||
impl Canonicalize for String {
|
||||
fn canonical(&self) -> Vec<u8> {
|
||||
self.as_bytes().to_vec()
|
||||
}
|
||||
}
|
||||
impl<T: Canonicalize + ?Sized> Canonicalize for &T {
|
||||
fn canonical(&self) -> Vec<u8> {
|
||||
(*self).canonical()
|
||||
}
|
||||
}
|
||||
|
||||
/// Rust compiler error canonical form.
|
||||
///
|
||||
/// Abstracts away:
|
||||
/// - file paths and line numbers (`src/foo.rs:42:5` → `<LOC>`)
|
||||
/// - string literals (`"foo"` → `<STR>`)
|
||||
/// - numeric literals (`42` → `<NUM>`)
|
||||
/// - panic wrappers (`thread 'main' panicked at '...'`)
|
||||
/// Keeps type tokens (`&str`, `String`, `i32`) and the
|
||||
/// `expected ... found ...` discriminating fragment.
|
||||
///
|
||||
/// Returns the canonical form. Two Rust errors with the same structural
|
||||
/// shape — different file, different line, different quoted string —
|
||||
/// collapse to the same bytes.
|
||||
pub fn rust_canonicalize(text: &str) -> Vec<u8> {
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
|
||||
static RE_LOC: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"[\w./\-]+\.rs:\d+:\d+").unwrap());
|
||||
static RE_STR: Lazy<Regex> = Lazy::new(|| Regex::new(r#""[^"]*""#).unwrap());
|
||||
static RE_NUM: Lazy<Regex> = Lazy::new(|| Regex::new(r"\b\d+\b").unwrap());
|
||||
static RE_WS: Lazy<Regex> = Lazy::new(|| Regex::new(r"\s+").unwrap());
|
||||
static RE_EXP: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"expected .+?, found [^\n]+").unwrap());
|
||||
|
||||
if text.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Strip panic wrappers of the form: `thread '...' panicked at '...'` or
|
||||
// backtrace frames. The leading `thread … panicked at '` is treated as a
|
||||
// wrapper, not part of the error.
|
||||
let body = unwrap_panic(text);
|
||||
|
||||
// Header = first line containing "error", else first non-empty line.
|
||||
let header = body
|
||||
.lines()
|
||||
.find(|l| l.contains("error"))
|
||||
.unwrap_or_else(|| body.lines().next().unwrap_or(""))
|
||||
.to_string();
|
||||
|
||||
let mut out = header;
|
||||
if let Some(m) = RE_EXP.find(body) {
|
||||
out.push_str(" | ");
|
||||
out.push_str(m.as_str().trim());
|
||||
}
|
||||
|
||||
// Apply abstractions in order: LOC → STR → NUM → WS.
|
||||
let s = RE_LOC.replace_all(&out, "<LOC>").into_owned();
|
||||
let s = RE_STR.replace_all(&s, "<STR>").into_owned();
|
||||
let s = RE_NUM.replace_all(&s, "<NUM>").into_owned();
|
||||
let s = RE_WS.replace_all(&s, " ").into_owned();
|
||||
s.into_bytes()
|
||||
}
|
||||
|
||||
fn unwrap_panic(text: &str) -> &str {
|
||||
// Pick out the inside of `panicked at '...'`. Multiple passes because
|
||||
// the wrapper may be nested (e.g., `panicked at 'panicked at ...'`).
|
||||
let mut s = text;
|
||||
while s.starts_with("thread") {
|
||||
if let Some(idx) = s.find("panicked at '") {
|
||||
let after = &s[idx + "panicked at '".len()..];
|
||||
// Find matching closing single quote at end (or end of string)
|
||||
let inner = after.trim_end();
|
||||
let inner = inner.strip_suffix('\'').unwrap_or(inner);
|
||||
s = inner;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Identity canonicalization (for non-Rust or generic inputs).
|
||||
pub fn identity_canonicalize(text: &str) -> Vec<u8> {
|
||||
text.as_bytes().to_vec()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fingerprint::fingerprint;
|
||||
|
||||
#[test]
|
||||
fn rust_canonical_same_error_different_path() {
|
||||
let a = rust_canonicalize(
|
||||
"error[E0308]: mismatched types\n --> src/foo.rs:42:5\n |\n expected i32, found &str",
|
||||
);
|
||||
let b = rust_canonicalize(
|
||||
"error[E0308]: mismatched types\n --> src/bar.rs:108:3\n |\n expected i32, found &str",
|
||||
);
|
||||
assert_eq!(a, b, "different path/line numbers must canonicalize equivalent");
|
||||
assert_eq!(
|
||||
fingerprint(&a),
|
||||
fingerprint(&b),
|
||||
"fingerprint must agree on equivalent canonical forms"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_canonical_distinguishes_types() {
|
||||
let a = rust_canonicalize("error[E0308]: expected i32, found &str");
|
||||
let b = rust_canonicalize("error[E0308]: expected i32, found String");
|
||||
assert_ne!(a, b, "&str vs String are different fixes and must be distinct");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_canonical_distinguishes_messages() {
|
||||
let a = rust_canonicalize("error[E0308]: expected i32, found &str");
|
||||
let b = rust_canonicalize("error[E0599]: method not found");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_passes_through() {
|
||||
for s in ["", "a", "hello world", "multi\nline"] {
|
||||
assert_eq!(identity_canonicalize(s), s.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trait_dispatch_for_str() {
|
||||
let s: &str = "hello";
|
||||
assert_eq!(s.canonical(), b"hello".to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trait_dispatch_for_string() {
|
||||
let s: String = String::from("hello");
|
||||
assert_eq!(s.canonical(), b"hello".to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_canonical_collapses_backtrace_prefix() {
|
||||
// Same logical error wrapped in 'thread main panicked at' must match
|
||||
// the bare form.
|
||||
let bare = rust_canonicalize("error[E0433]: failed to resolve\n --> foo.rs:1:1");
|
||||
let wrapped = rust_canonicalize("thread 'main' panicked at 'error[E0433]: failed to resolve\n --> foo.rs:1:1'");
|
||||
assert_eq!(bare, wrapped, "backtrace wrapper must not change fingerprint");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Domain-separated SHA3-256 fingerprint.
|
||||
//!
|
||||
//! The contract: `fingerprint(body)` is deterministic. Same canonicalized
|
||||
//! body → same 32 bytes, regardless of when or where.
|
||||
//!
|
||||
//! Scope is a separate lookup key (see `crate::lattice::Lattice`), not
|
||||
//! part of the fingerprint — so a record under `global` is found via
|
||||
//! `ask("repo:other", ...)` via the lattice's scope fallback.
|
||||
|
||||
use sha3::{Digest, Sha3_256};
|
||||
use std::fmt;
|
||||
|
||||
pub struct Fingerprint(pub [u8; 32]);
|
||||
|
||||
impl Fingerprint {
|
||||
/// Borrow the underlying 32 bytes (zero-copy). Used by `hex_encode`
|
||||
/// and the on-disk ledger.
|
||||
pub fn as_bytes(&self) -> &[u8; 32] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// SHA3-256 of the canonicalized body alone. Scope is a separate lookup
|
||||
/// key in the lattice; not part of the signature.
|
||||
pub fn fingerprint(body: &[u8]) -> Fingerprint {
|
||||
let mut h = Sha3_256::new();
|
||||
h.update(body);
|
||||
Fingerprint(h.finalize().into())
|
||||
}
|
||||
|
||||
impl fmt::Display for Fingerprint {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
for b in &self.0 {
|
||||
write!(f, "{:02x}", b)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Fingerprint {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "Fingerprint({})", self)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Fingerprint {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.0 == other.0
|
||||
}
|
||||
}
|
||||
impl Eq for Fingerprint {}
|
||||
impl std::hash::Hash for Fingerprint {
|
||||
fn hash<H: std::hash::Hasher>(&self, h: &mut H) {
|
||||
self.0.hash(h);
|
||||
}
|
||||
}
|
||||
impl Clone for Fingerprint {
|
||||
fn clone(&self) -> Self {
|
||||
*self
|
||||
}
|
||||
}
|
||||
impl Copy for Fingerprint {}
|
||||
|
||||
/// Encode 32 raw bytes to a 64-char hex string. The on-disk ledger
|
||||
/// stores signatures as hex for human readability; in-memory indexes
|
||||
/// use the raw 32-byte form.
|
||||
pub fn hex_encode(b: &[u8; 32]) -> String {
|
||||
let mut s = String::with_capacity(64);
|
||||
for byte in b {
|
||||
s.push_str(&format!("{:02x}", byte));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Decode a 64-char hex string to 32 bytes. Was previously a `Vec<u8>`
|
||||
/// allocator — now stack-allocated via fixed array.
|
||||
pub fn hex_decode(s: &str) -> Option<[u8; 32]> {
|
||||
if s.len() != 64 {
|
||||
return None;
|
||||
}
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = [0u8; 32];
|
||||
let mut i = 0;
|
||||
while i < 32 {
|
||||
let h = (bytes[i * 2] as char).to_digit(16)?;
|
||||
let l = (bytes[i * 2 + 1] as char).to_digit(16)?;
|
||||
out[i] = (h * 16 + l) as u8;
|
||||
i += 1;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fingerprint_determinism() {
|
||||
let a = fingerprint(b"hello world");
|
||||
let b = fingerprint(b"hello world");
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_distinguishes_body() {
|
||||
let a = fingerprint(b"hello");
|
||||
let b = fingerprint(b"world");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_still_hashes() {
|
||||
let sig = fingerprint(b"");
|
||||
assert_ne!(sig.0, [0u8; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_round_trip() {
|
||||
let sig = fingerprint(b"y");
|
||||
let s = hex_encode(&sig.0);
|
||||
assert_eq!(s.len(), 64);
|
||||
let back = hex_decode(&s).unwrap();
|
||||
assert_eq!(back, sig.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_decode_rejects_bad_length() {
|
||||
assert!(hex_decode("").is_none());
|
||||
assert!(hex_decode("0123").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_decode_rejects_non_hex() {
|
||||
let mut s = String::from("0".repeat(64));
|
||||
s.replace_range(0..1, "Z");
|
||||
assert!(hex_decode(&s).is_none());
|
||||
}
|
||||
}
|
||||
+414
@@ -0,0 +1,414 @@
|
||||
//! Append-only ledger of facts.
|
||||
//!
|
||||
//! Every Outcome is a `(signature, scope, body, worked?, ts)` tuple.
|
||||
//! Lookups prefer the requested scope, fall back to "global". The
|
||||
//! ledger is replayed on open() into the in-memory aggregate; new
|
||||
//! writes are appended and applied.
|
||||
//!
|
||||
//! Lookups are ephemeral; Outcomes are durable. Compact prunes old
|
||||
//! lookups by timestamp; Outcomes are kept (latest write wins per
|
||||
//! (signature, scope)).
|
||||
|
||||
use crate::fingerprint::{hex_decode, hex_encode, Fingerprint};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum Event {
|
||||
Outcome {
|
||||
signature: String,
|
||||
scope: String,
|
||||
body: Vec<u8>,
|
||||
worked: Option<bool>,
|
||||
ts: u64,
|
||||
},
|
||||
Lookup {
|
||||
signature: String,
|
||||
scope: String,
|
||||
hit: bool,
|
||||
ts: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Hit returned from a lookup. The body is shared via `Arc<Vec<u8>>`
|
||||
/// so a hit costs one refcount bump, not a memcpy.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Hit {
|
||||
pub value: Arc<Vec<u8>>,
|
||||
pub scope: String,
|
||||
pub worked: Option<bool>,
|
||||
}
|
||||
|
||||
pub struct Lattice {
|
||||
file: File,
|
||||
path: PathBuf,
|
||||
/// sig → (scope → value + worked). Bodies are Arc-shared so a
|
||||
/// lookup returns a clone without copying the bytes.
|
||||
by_sig: HashMap<Fingerprint, HashMap<String, Stored>>,
|
||||
pub outcomes: u64,
|
||||
pub lookups: u64,
|
||||
pub hits: u64,
|
||||
pub misses: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Stored {
|
||||
body: Arc<Vec<u8>>,
|
||||
worked: Option<bool>,
|
||||
}
|
||||
|
||||
fn now_ts() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
impl Lattice {
|
||||
/// Open (or create) a lattice in `dir`. Replays any existing ledger
|
||||
/// into in-memory aggregates.
|
||||
pub fn open(dir: &Path) -> std::io::Result<Self> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let path = dir.join("events.jsonl");
|
||||
let file = OpenOptions::new().create(true).append(true).open(&path)?;
|
||||
|
||||
let mut lat = Self {
|
||||
file,
|
||||
path,
|
||||
by_sig: HashMap::new(),
|
||||
outcomes: 0,
|
||||
lookups: 0,
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
};
|
||||
|
||||
if let Ok(f) = File::open(&lat.path) {
|
||||
for line in BufReader::new(f).lines().flatten() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(ev) = serde_json::from_str::<Event>(&line) {
|
||||
lat.apply(&ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(lat)
|
||||
}
|
||||
|
||||
fn apply(&mut self, ev: &Event) {
|
||||
match ev {
|
||||
Event::Outcome {
|
||||
signature,
|
||||
scope,
|
||||
body,
|
||||
worked,
|
||||
..
|
||||
} => {
|
||||
self.outcomes += 1;
|
||||
if let Some(sig_bytes) = hex_decode(signature) {
|
||||
let stored = Stored {
|
||||
body: Arc::new(body.clone()),
|
||||
worked: *worked,
|
||||
};
|
||||
let by_scope = self.by_sig.entry(Fingerprint(sig_bytes)).or_default();
|
||||
by_scope.insert(scope.clone(), stored);
|
||||
}
|
||||
}
|
||||
Event::Lookup { hit, .. } => {
|
||||
self.lookups += 1;
|
||||
if *hit {
|
||||
self.hits += 1;
|
||||
} else {
|
||||
self.misses += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Append an event to the ledger. Writes directly to the underlying
|
||||
/// `File` via `serde_json::to_writer` (no intermediate String
|
||||
/// allocation).
|
||||
fn append(&mut self, ev: &Event) {
|
||||
if serde_json::to_writer(&mut self.file, ev).is_ok() {
|
||||
let _ = writeln!(self.file);
|
||||
let _ = self.file.flush();
|
||||
}
|
||||
self.apply(ev);
|
||||
}
|
||||
|
||||
/// Record an Outcome: this (signature, scope) has value `body`
|
||||
/// with `worked` outcome.
|
||||
pub fn record(
|
||||
&mut self,
|
||||
sig: Fingerprint,
|
||||
scope: &str,
|
||||
body: &[u8],
|
||||
worked: Option<bool>,
|
||||
) {
|
||||
self.append(&Event::Outcome {
|
||||
signature: hex_encode(sig.as_bytes()),
|
||||
scope: scope.to_string(),
|
||||
body: body.to_vec(),
|
||||
worked,
|
||||
ts: now_ts(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Look up a Hit for `sig` in `scope`. Falls back to "global".
|
||||
/// Logs the lookup (hit/miss) to the ledger.
|
||||
pub fn lookup(&mut self, sig: &Fingerprint, scope: &str) -> Option<Hit> {
|
||||
let hit = self.by_sig.get(sig).and_then(|by_scope| {
|
||||
by_scope
|
||||
.get(scope)
|
||||
.or_else(|| by_scope.get("global"))
|
||||
.map(|s| Hit {
|
||||
value: s.body.clone(),
|
||||
scope: scope.to_string(),
|
||||
worked: s.worked,
|
||||
})
|
||||
});
|
||||
|
||||
self.append(&Event::Lookup {
|
||||
signature: hex_encode(sig.as_bytes()),
|
||||
scope: scope.to_string(),
|
||||
hit: hit.is_some(),
|
||||
ts: now_ts(),
|
||||
});
|
||||
|
||||
hit
|
||||
}
|
||||
|
||||
pub fn record_count(&self) -> usize {
|
||||
self.by_sig.len()
|
||||
}
|
||||
|
||||
pub fn hit_rate(&self) -> f64 {
|
||||
if self.lookups == 0 {
|
||||
0.0
|
||||
} else {
|
||||
self.hits as f64 / self.lookups as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop Lookup events older than `retention_secs`. Outcomes are
|
||||
/// collapsed to keep the latest per (signature, scope). Returns
|
||||
/// count of dropped Lookup events.
|
||||
pub fn compact(&mut self, retention_secs: u64) -> std::io::Result<usize> {
|
||||
let f = File::open(&self.path)?;
|
||||
let reader = BufReader::new(f);
|
||||
let mut events: Vec<Event> = Vec::new();
|
||||
|
||||
for line in reader.lines().flatten() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(ev) = serde_json::from_str::<Event>(&line) {
|
||||
events.push(ev);
|
||||
}
|
||||
}
|
||||
|
||||
let now = now_ts();
|
||||
let mut dropped = 0usize;
|
||||
let mut kept_outcomes: HashMap<(String, String), Event> = HashMap::new();
|
||||
let mut kept_lookups: Vec<Event> = Vec::new();
|
||||
|
||||
for ev in events {
|
||||
match ev {
|
||||
Event::Lookup {
|
||||
signature: sig_str,
|
||||
scope,
|
||||
hit,
|
||||
ts,
|
||||
} => {
|
||||
if now.saturating_sub(ts) > retention_secs {
|
||||
dropped += 1;
|
||||
} else {
|
||||
kept_lookups.push(Event::Lookup {
|
||||
signature: sig_str,
|
||||
scope,
|
||||
hit,
|
||||
ts,
|
||||
});
|
||||
}
|
||||
}
|
||||
Event::Outcome {
|
||||
signature,
|
||||
scope,
|
||||
body,
|
||||
worked,
|
||||
ts,
|
||||
} => {
|
||||
let key = (signature.clone(), scope.clone());
|
||||
kept_outcomes.insert(
|
||||
key,
|
||||
Event::Outcome {
|
||||
signature,
|
||||
scope,
|
||||
body,
|
||||
worked,
|
||||
ts,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tmp = self.path.with_extension("jsonl.tmp");
|
||||
let mut out = File::create(&tmp)?;
|
||||
for e in kept_outcomes.values() {
|
||||
serde_json::to_writer(&mut out, e)?;
|
||||
writeln!(out)?;
|
||||
}
|
||||
for e in &kept_lookups {
|
||||
serde_json::to_writer(&mut out, e)?;
|
||||
writeln!(out)?;
|
||||
}
|
||||
out.flush()?;
|
||||
drop(out);
|
||||
|
||||
std::fs::rename(&tmp, &self.path)?;
|
||||
self.file = OpenOptions::new().append(true).open(&self.path)?;
|
||||
|
||||
Ok(dropped)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fingerprint::fingerprint;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut lat = Lattice::open(dir.path()).unwrap();
|
||||
|
||||
let sig = fingerprint(b"test failure");
|
||||
lat.record(sig, "global", b"use .to_string()", Some(true));
|
||||
|
||||
let hit = lat.lookup(&sig, "global").expect("round-trip miss");
|
||||
assert_eq!(hit.value.as_ref(), b"use .to_string()");
|
||||
assert_eq!(hit.worked, Some(true));
|
||||
|
||||
assert_eq!(lat.outcomes, 1);
|
||||
assert_eq!(lat.lookups, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cold_lookup_misses() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut lat = Lattice::open(dir.path()).unwrap();
|
||||
|
||||
let sig = fingerprint(b"never seen");
|
||||
let hit = lat.lookup(&sig, "global");
|
||||
assert!(hit.is_none());
|
||||
assert_eq!(lat.misses, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_prefer_with_global_fallback() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut lat = Lattice::open(dir.path()).unwrap();
|
||||
|
||||
let sig = fingerprint(b"y");
|
||||
lat.record(sig, "global", b"global fix", Some(true));
|
||||
lat.record(sig, "repo:acme/widget", b"repo fix", Some(true));
|
||||
|
||||
let hit = lat.lookup(&sig, "repo:acme/widget").unwrap();
|
||||
assert_eq!(hit.value.as_ref(), b"repo fix");
|
||||
|
||||
let hit = lat.lookup(&sig, "repo:other/thing").unwrap();
|
||||
assert_eq!(hit.value.as_ref(), b"global fix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persists_across_reopen() {
|
||||
let dir = tempdir().unwrap();
|
||||
{
|
||||
let mut lat = Lattice::open(dir.path()).unwrap();
|
||||
let sig = fingerprint(b"persist");
|
||||
lat.record(sig, "global", b"survives", Some(true));
|
||||
}
|
||||
let mut lat2 = Lattice::open(dir.path()).unwrap();
|
||||
let sig = fingerprint(b"persist");
|
||||
let hit = lat2.lookup(&sig, "global").expect("survived restart");
|
||||
assert_eq!(hit.value.as_ref(), b"survives");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_drops_old_lookups_keeps_outcomes() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut lat = Lattice::open(dir.path()).unwrap();
|
||||
|
||||
let sig = fingerprint(b"x");
|
||||
lat.record(sig, "global", b"v", Some(true));
|
||||
|
||||
// Append a back-dated lookup directly to the ledger.
|
||||
let path = dir.path().join("events.jsonl");
|
||||
let old_text = std::fs::read_to_string(&path).unwrap();
|
||||
let old_ts = 1u64;
|
||||
let stale_lookup = format!(
|
||||
r#"{{"kind":"Lookup","signature":"{}","scope":"global","hit":true,"ts":{}}}"#,
|
||||
sig.to_string(),
|
||||
old_ts
|
||||
);
|
||||
std::fs::write(&path, format!("{}\n{}\n", old_text, stale_lookup)).unwrap();
|
||||
|
||||
let mut lat = Lattice::open(dir.path()).unwrap();
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
assert!(now > old_ts + 100, "test pre-condition: stale ts must be old");
|
||||
|
||||
let dropped = lat.compact(100).unwrap();
|
||||
assert_eq!(dropped, 1);
|
||||
|
||||
let hit = lat.lookup(&sig, "global").expect("outcome survives");
|
||||
assert_eq!(hit.value.as_ref(), b"v");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_count_distinct_signatures() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut lat = Lattice::open(dir.path()).unwrap();
|
||||
lat.record(fingerprint(b"a"), "global", b"v1", Some(true));
|
||||
lat.record(fingerprint(b"b"), "global", b"v2", Some(true));
|
||||
lat.record(fingerprint(b"a"), "global", b"v1b", Some(true));
|
||||
assert_eq!(lat.record_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_rate_computed() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut lat = Lattice::open(dir.path()).unwrap();
|
||||
let sig = fingerprint(b"present");
|
||||
lat.record(sig, "global", b"v", Some(true));
|
||||
lat.lookup(&sig, "global");
|
||||
lat.lookup(&sig, "global");
|
||||
lat.lookup(&fingerprint(b"missing"), "global");
|
||||
assert!((lat.hit_rate() - 2.0 / 3.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_returns_arc_shared_body() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut lat = Lattice::open(dir.path()).unwrap();
|
||||
let sig = fingerprint(b"x");
|
||||
lat.record(sig, "global", b"hello world", Some(true));
|
||||
|
||||
let hit1 = lat.lookup(&sig, "global").unwrap();
|
||||
let hit2 = lat.lookup(&sig, "global").unwrap();
|
||||
|
||||
assert!(Arc::ptr_eq(&hit1.value, &hit2.value),
|
||||
"lookups must share the underlying body via Arc, not memcpy");
|
||||
}
|
||||
}
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
//! aether-atom — the atomic primitive of the aether-tools universe.
|
||||
//!
|
||||
//! Three operations comprise the entire system:
|
||||
//!
|
||||
//! 1. **`canonicalize`** — collapse equivalent inputs to a stable byte
|
||||
//! form. Two callers providing the same logical input get the same
|
||||
//! canonical bytes regardless of surface differences.
|
||||
//!
|
||||
//! 2. **`record`** — append `(sig, scope, body, worked?, ts)` to a
|
||||
//! ledger. Auditable, replayable, hash-chained by construction.
|
||||
//!
|
||||
//! 3. **`ask`** — cache-or-compute: hit returns cached body; miss calls
|
||||
//! `compute(input)`, records, returns.
|
||||
//!
|
||||
//! Everything else in aether-tools — MCP servers, DHT nodes, REPLs,
|
||||
//! HTML dashboards, ratatui panels — is a thin binding around these
|
||||
//! three.
|
||||
|
||||
pub mod canonical;
|
||||
pub mod fingerprint;
|
||||
pub mod lattice;
|
||||
|
||||
pub use canonical::{identity_canonicalize, rust_canonicalize, Canonicalize};
|
||||
pub use fingerprint::{fingerprint, hex_decode, hex_encode, Fingerprint};
|
||||
pub use lattice::{Event, Hit, Lattice};
|
||||
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Resolve the canonical data directory for aether-atom storage.
|
||||
///
|
||||
/// Read order:
|
||||
/// 1. `<env_var>` (e.g. `AETHER_RECALL_DIR` or `AETHER_NET_DATA_DIR`)
|
||||
/// 2. `$HOME/.aether-recall/`
|
||||
/// 3. `./.aether-recall/`
|
||||
///
|
||||
/// Used by every wrapper binary so renaming/storage location is a
|
||||
/// single env-var change. Putting it here means a future systemd unit
|
||||
/// or docker image has exactly one switch to flip.
|
||||
pub fn data_dir(env_var: &str) -> PathBuf {
|
||||
if let Ok(d) = env::var(env_var) {
|
||||
return PathBuf::from(d);
|
||||
}
|
||||
if let Ok(h) = env::var("HOME") {
|
||||
return PathBuf::from(h).join(".aether-recall");
|
||||
}
|
||||
PathBuf::from(".aether-recall")
|
||||
}
|
||||
|
||||
/// Cache-or-compute. The signature is content-only. The scope is a
|
||||
/// lookup key with global fallback. Returns body bytes — cached or
|
||||
/// freshly computed.
|
||||
pub fn ask<I: Canonicalize + ?Sized>(
|
||||
lattice: &mut Lattice,
|
||||
scope: &str,
|
||||
input: &I,
|
||||
compute: impl FnOnce(&I) -> Vec<u8>,
|
||||
) -> Vec<u8> {
|
||||
let sig = fingerprint(&input.canonical());
|
||||
if let Some(hit) = lattice.lookup(&sig, scope) {
|
||||
return hit.value.as_ref().clone();
|
||||
}
|
||||
let value = compute(input);
|
||||
lattice.record(sig, scope, &value, Some(true));
|
||||
value
|
||||
}
|
||||
|
||||
/// Tell the ledger that a previously-issued value did NOT work. Marks
|
||||
/// the cached entry as `worked = Some(false)` so stats reflect demotion;
|
||||
/// the body is preserved so callers can inspect what was tried.
|
||||
pub fn demote<I: Canonicalize + ?Sized>(
|
||||
lattice: &mut Lattice,
|
||||
scope: &str,
|
||||
input: &I,
|
||||
failure_msg: &[u8],
|
||||
) {
|
||||
let sig = fingerprint(&input.canonical());
|
||||
lattice.record(sig, scope, failure_msg, Some(false));
|
||||
}
|
||||
|
||||
/// Explicitly remember a value (the seeding operator). The signature
|
||||
/// is content-only. If a record already exists for `(sig, scope)`,
|
||||
/// this overwrites it.
|
||||
pub fn remember<I: Canonicalize + ?Sized>(
|
||||
lattice: &mut Lattice,
|
||||
scope: &str,
|
||||
input: &I,
|
||||
value: Vec<u8>,
|
||||
worked: Option<bool>,
|
||||
) {
|
||||
let sig = fingerprint(&input.canonical());
|
||||
lattice.record(sig, scope, &value, worked);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn ask_returns_cached_when_present() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut lat = Lattice::open(dir.path()).unwrap();
|
||||
|
||||
remember(&mut lat, "global", "hello", b"hi".to_vec(), Some(true));
|
||||
assert_eq!(lat.outcomes, 1);
|
||||
|
||||
let mut called = false;
|
||||
let v = ask(&mut lat, "global", &"hello", |_| {
|
||||
called = true;
|
||||
b"different".to_vec()
|
||||
});
|
||||
assert_eq!(v, b"hi");
|
||||
assert!(!called, "must not call compute on cache hit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_computes_and_records_on_miss() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut lat = Lattice::open(dir.path()).unwrap();
|
||||
|
||||
let mut called = false;
|
||||
let v = ask(&mut lat, "global", &"new", |_| {
|
||||
called = true;
|
||||
b"computed".to_vec()
|
||||
});
|
||||
assert_eq!(v, b"computed");
|
||||
assert!(called);
|
||||
assert_eq!(lat.outcomes, 1);
|
||||
|
||||
let mut called2 = false;
|
||||
let v2 = ask(&mut lat, "global", &"new", |_| {
|
||||
called2 = true;
|
||||
b"recomputed".to_vec()
|
||||
});
|
||||
assert_eq!(v2, b"computed");
|
||||
assert!(!called2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_uses_canonical_form_for_lookup_key() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut lat = Lattice::open(dir.path()).unwrap();
|
||||
|
||||
let canon = rust_canonicalize("error[E0308]: expected i32, found &str");
|
||||
let sig = fingerprint(&canon);
|
||||
lat.record(sig, "global", b"use i32", Some(true));
|
||||
|
||||
let mut called_a = false;
|
||||
let mut called_b = false;
|
||||
|
||||
let canon_a = rust_canonicalize(
|
||||
"error[E0308]: expected i32, found &str\n --> src/foo.rs:42:5",
|
||||
);
|
||||
let _ = ask_canon(&mut lat, "global", &canon_a, || {
|
||||
called_a = true;
|
||||
b"x".to_vec()
|
||||
});
|
||||
|
||||
let canon_b = rust_canonicalize("error[E0308]: expected i32, found &str");
|
||||
let _ = ask_canon(&mut lat, "global", &canon_b, || {
|
||||
called_b = true;
|
||||
b"y".to_vec()
|
||||
});
|
||||
|
||||
assert!(!called_a, "different path lines must canonicalize equivalent");
|
||||
assert!(!called_b);
|
||||
}
|
||||
|
||||
fn ask_canon(
|
||||
lat: &mut Lattice,
|
||||
scope: &str,
|
||||
canon: &[u8],
|
||||
compute: impl FnOnce() -> Vec<u8>,
|
||||
) -> Vec<u8> {
|
||||
let sig = fingerprint(canon);
|
||||
if let Some(hit) = lat.lookup(&sig, scope) {
|
||||
return hit.value.as_ref().clone();
|
||||
}
|
||||
let v = compute();
|
||||
lat.record(sig, scope, &v, Some(true));
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demote_marks_worked_false() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut lat = Lattice::open(dir.path()).unwrap();
|
||||
|
||||
let _ = ask(&mut lat, "global", &"hello", |_| b"value".to_vec());
|
||||
assert_eq!(
|
||||
lat.lookup(&fingerprint(&"hello".canonical()), "global")
|
||||
.unwrap()
|
||||
.worked,
|
||||
Some(true)
|
||||
);
|
||||
|
||||
demote(&mut lat, "global", &"hello", b"value");
|
||||
assert_eq!(
|
||||
lat.lookup(&fingerprint(&"hello".canonical()), "global")
|
||||
.unwrap()
|
||||
.worked,
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_fallback_in_ask() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut lat = Lattice::open(dir.path()).unwrap();
|
||||
|
||||
remember(&mut lat, "global", "key", b"v".to_vec(), Some(true));
|
||||
|
||||
let v = ask(&mut lat, "repo:other", &"key", |_| b"diff".to_vec());
|
||||
assert_eq!(
|
||||
v, b"v",
|
||||
"ask must consult the global fallback when scope is empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atom_is_three_ops_total() {
|
||||
// The atom is exactly three operations: ask, remember, demote.
|
||||
// Any fourth operation breaks this test by adding a fourth
|
||||
// call here.
|
||||
let dir = tempdir().unwrap();
|
||||
let mut lat = Lattice::open(dir.path()).unwrap();
|
||||
remember(&mut lat, "global", &"seed", b"v".to_vec(), Some(true));
|
||||
let _ = ask(&mut lat, "global", &"seed", |_| Vec::new());
|
||||
demote(&mut lat, "global", &"seed", b"v");
|
||||
assert_eq!(
|
||||
lat.lookup(&fingerprint(&"seed".canonical()), "global")
|
||||
.unwrap()
|
||||
.worked,
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_shares_arc_body() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut lat = Lattice::open(dir.path()).unwrap();
|
||||
lat.record(fingerprint(b"shared"), "global", b"hello world", Some(true));
|
||||
let h1 = lat.lookup(&fingerprint(b"shared"), "global").unwrap();
|
||||
let h2 = lat.lookup(&fingerprint(b"shared"), "global").unwrap();
|
||||
assert!(
|
||||
std::sync::Arc::ptr_eq(&h1.value, &h2.value),
|
||||
"multiple lookups share Arc<Vec<u8>> — no memcpy on hit"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user