clawpowers 1.1.4 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +126 -0
- package/COMPATIBILITY.md +13 -0
- package/KNOWN_LIMITATIONS.md +19 -0
- package/LICENSE +44 -0
- package/LICENSING.md +10 -0
- package/README.md +378 -210
- package/SECURITY.md +52 -0
- package/dist/index.d.ts +1477 -0
- package/dist/index.js +3464 -0
- package/dist/index.js.map +1 -0
- package/native/Cargo.lock +4863 -0
- package/native/Cargo.toml +73 -0
- package/native/crates/canonical/Cargo.toml +24 -0
- package/native/crates/canonical/src/lib.rs +673 -0
- package/native/crates/compression/Cargo.toml +20 -0
- package/native/crates/compression/benches/compression_bench.rs +42 -0
- package/native/crates/compression/src/lib.rs +393 -0
- package/native/crates/evm-eth/Cargo.toml +13 -0
- package/native/crates/evm-eth/src/lib.rs +105 -0
- package/native/crates/fee/Cargo.toml +15 -0
- package/native/crates/fee/src/lib.rs +281 -0
- package/native/crates/index/Cargo.toml +16 -0
- package/native/crates/index/src/lib.rs +277 -0
- package/native/crates/policy/Cargo.toml +17 -0
- package/native/crates/policy/src/lib.rs +614 -0
- package/native/crates/security/Cargo.toml +22 -0
- package/native/crates/security/src/lib.rs +478 -0
- package/native/crates/tokens/Cargo.toml +13 -0
- package/native/crates/tokens/src/lib.rs +534 -0
- package/native/crates/verification/Cargo.toml +23 -0
- package/native/crates/verification/src/lib.rs +333 -0
- package/native/crates/wallet/Cargo.toml +20 -0
- package/native/crates/wallet/src/lib.rs +261 -0
- package/native/crates/x402/Cargo.toml +30 -0
- package/native/crates/x402/src/lib.rs +423 -0
- package/native/ffi/Cargo.toml +34 -0
- package/native/ffi/build.rs +4 -0
- package/native/ffi/index.node +0 -0
- package/native/ffi/src/lib.rs +352 -0
- package/native/ffi/tests/integration.rs +354 -0
- package/native/pyo3/Cargo.toml +26 -0
- package/native/pyo3/pyproject.toml +16 -0
- package/native/pyo3/src/lib.rs +407 -0
- package/native/pyo3/tests/test_smoke.py +180 -0
- package/native/wasm/Cargo.toml +44 -0
- package/native/wasm/pkg/.gitignore +6 -0
- package/native/wasm/pkg/clawpowers_wasm.d.ts +208 -0
- package/native/wasm/pkg/clawpowers_wasm.js +872 -0
- package/native/wasm/pkg/clawpowers_wasm_bg.wasm +0 -0
- package/native/wasm/pkg/clawpowers_wasm_bg.wasm.d.ts +40 -0
- package/native/wasm/pkg/package.json +17 -0
- package/native/wasm/pkg-node/.gitignore +6 -0
- package/native/wasm/pkg-node/clawpowers_wasm.d.ts +143 -0
- package/native/wasm/pkg-node/clawpowers_wasm.js +798 -0
- package/native/wasm/pkg-node/clawpowers_wasm_bg.wasm +0 -0
- package/native/wasm/pkg-node/clawpowers_wasm_bg.wasm.d.ts +40 -0
- package/native/wasm/pkg-node/package.json +13 -0
- package/native/wasm/src/lib.rs +433 -0
- package/package.json +71 -44
- package/src/skills/catalog.ts +435 -0
- package/src/skills/executor.ts +56 -0
- package/src/skills/index.ts +3 -0
- package/src/skills/itp/SKILL.md +112 -0
- package/src/skills/loader.ts +193 -0
- package/.claude-plugin/manifest.json +0 -19
- package/.codex/INSTALL.md +0 -36
- package/.cursor-plugin/manifest.json +0 -21
- package/.opencode/INSTALL.md +0 -52
- package/ARCHITECTURE.md +0 -69
- package/bin/clawpowers.js +0 -625
- package/bin/clawpowers.sh +0 -91
- package/docs/demo/clawpowers-demo.cast +0 -197
- package/docs/demo/clawpowers-demo.gif +0 -0
- package/docs/launch-images/25-skills-breakdown.jpg +0 -0
- package/docs/launch-images/clawpowers-vs-superpowers.jpg +0 -0
- package/docs/launch-images/economic-code-optimization.jpg +0 -0
- package/docs/launch-images/native-vs-bridge-2.jpg +0 -0
- package/docs/launch-images/native-vs-bridge.jpg +0 -0
- package/docs/launch-images/post1-hero-lobster.jpg +0 -0
- package/docs/launch-images/post2-dashboard.jpg +0 -0
- package/docs/launch-images/post3-superpowers.jpg +0 -0
- package/docs/launch-images/post4-before-after.jpg +0 -0
- package/docs/launch-images/post5-install-now.jpg +0 -0
- package/docs/launch-images/ultimate-stack.jpg +0 -0
- package/docs/launch-posts.md +0 -76
- package/docs/quickstart-first-transaction.md +0 -204
- package/gemini-extension.json +0 -32
- package/hooks/session-start +0 -205
- package/hooks/session-start.cmd +0 -43
- package/hooks/session-start.js +0 -163
- package/runtime/demo/README.md +0 -78
- package/runtime/demo/x402-mock-server.js +0 -230
- package/runtime/feedback/analyze.js +0 -621
- package/runtime/feedback/analyze.sh +0 -546
- package/runtime/init.js +0 -210
- package/runtime/init.sh +0 -178
- package/runtime/metrics/collector.js +0 -361
- package/runtime/metrics/collector.sh +0 -308
- package/runtime/payments/ledger.js +0 -305
- package/runtime/payments/ledger.sh +0 -262
- package/runtime/payments/pipeline.js +0 -455
- package/runtime/persistence/store.js +0 -433
- package/runtime/persistence/store.sh +0 -303
- package/skill.json +0 -106
- package/skills/agent-bounties/SKILL.md +0 -553
- package/skills/agent-payments/SKILL.md +0 -479
- package/skills/brainstorming/SKILL.md +0 -233
- package/skills/content-pipeline/SKILL.md +0 -282
- package/skills/cross-project-knowledge/SKILL.md +0 -345
- package/skills/dispatching-parallel-agents/SKILL.md +0 -305
- package/skills/economic-code-optimization/SKILL.md +0 -265
- package/skills/executing-plans/SKILL.md +0 -255
- package/skills/finishing-a-development-branch/SKILL.md +0 -260
- package/skills/formal-verification-lite/SKILL.md +0 -441
- package/skills/learn-how-to-learn/SKILL.md +0 -235
- package/skills/market-intelligence/SKILL.md +0 -323
- package/skills/meta-skill-evolution/SKILL.md +0 -325
- package/skills/prospecting/SKILL.md +0 -454
- package/skills/receiving-code-review/SKILL.md +0 -225
- package/skills/requesting-code-review/SKILL.md +0 -206
- package/skills/security-audit/SKILL.md +0 -353
- package/skills/self-healing-code/SKILL.md +0 -369
- package/skills/subagent-driven-development/SKILL.md +0 -244
- package/skills/systematic-debugging/SKILL.md +0 -355
- package/skills/test-driven-development/SKILL.md +0 -416
- package/skills/using-clawpowers/SKILL.md +0 -160
- package/skills/using-git-worktrees/SKILL.md +0 -261
- package/skills/validator/SKILL.md +0 -281
- package/skills/verification-before-completion/SKILL.md +0 -254
- package/skills/writing-plans/SKILL.md +0 -276
- package/skills/writing-skills/SKILL.md +0 -260
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
//! clawpowers-fee — Fee schedule calculation for transactions and swaps.
|
|
2
|
+
//!
|
|
3
|
+
//! [`FeeSchedule`] holds basis-point rates for different operation types and
|
|
4
|
+
//! computes the fee for a given [`TokenAmount`].
|
|
5
|
+
|
|
6
|
+
use alloy_primitives::Address;
|
|
7
|
+
use clawpowers_tokens::{TokenAmount, TokenError};
|
|
8
|
+
use serde::{Deserialize, Serialize};
|
|
9
|
+
use thiserror::Error;
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Constants
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/// Default transaction fee: 77 bps = 0.77%.
|
|
16
|
+
pub const DEFAULT_TX_FEE_BPS: u64 = 77;
|
|
17
|
+
|
|
18
|
+
/// Default swap fee: 30 bps = 0.30%.
|
|
19
|
+
pub const DEFAULT_SWAP_FEE_BPS: u64 = 30;
|
|
20
|
+
|
|
21
|
+
/// Placeholder fee recipient address used by [`FeeSchedule::default`].
|
|
22
|
+
pub const PLACEHOLDER_FEE_RECIPIENT: Address = Address::ZERO;
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// FeeType
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/// Fee classification.
|
|
29
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
30
|
+
#[serde(rename_all = "snake_case")]
|
|
31
|
+
pub enum FeeType {
|
|
32
|
+
/// Standard on-chain transaction fee.
|
|
33
|
+
Transaction,
|
|
34
|
+
/// DEX swap fee.
|
|
35
|
+
Swap,
|
|
36
|
+
/// Custom fee with explicit basis points.
|
|
37
|
+
Custom(u64),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Errors
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/// Errors produced by fee operations.
|
|
45
|
+
#[derive(Debug, Error, PartialEq, Eq)]
|
|
46
|
+
pub enum FeeError {
|
|
47
|
+
/// Token arithmetic failed.
|
|
48
|
+
#[error("token arithmetic error: {0}")]
|
|
49
|
+
Token(String),
|
|
50
|
+
/// Basis-point rate exceeds 10 000 (100%).
|
|
51
|
+
#[error("invalid basis points: {0} (max 10000)")]
|
|
52
|
+
InvalidBps(u64),
|
|
53
|
+
/// Computed fee would exceed the gross amount.
|
|
54
|
+
#[error("fee ({fee_bps} bps) would exceed gross amount")]
|
|
55
|
+
FeeExceedsAmount {
|
|
56
|
+
/// The basis-point rate that caused the violation.
|
|
57
|
+
fee_bps: u64,
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
impl From<TokenError> for FeeError {
|
|
62
|
+
fn from(e: TokenError) -> Self {
|
|
63
|
+
FeeError::Token(e.to_string())
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// FeeCalculation
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/// The result of a fee calculation.
|
|
72
|
+
#[derive(Debug, Clone)]
|
|
73
|
+
pub struct FeeCalculation {
|
|
74
|
+
/// The original, pre-fee amount.
|
|
75
|
+
pub gross_amount: TokenAmount,
|
|
76
|
+
/// The fee portion deducted from `gross_amount`.
|
|
77
|
+
pub fee_amount: TokenAmount,
|
|
78
|
+
/// The amount remaining after fee deduction.
|
|
79
|
+
pub net_amount: TokenAmount,
|
|
80
|
+
/// The address that will receive the fee.
|
|
81
|
+
pub fee_recipient: Address,
|
|
82
|
+
/// The type of fee that was applied.
|
|
83
|
+
pub fee_type: FeeType,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// FeeSchedule
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/// Configures fee rates and the recipient address for the protocol.
|
|
91
|
+
#[derive(Debug, Clone)]
|
|
92
|
+
pub struct FeeSchedule {
|
|
93
|
+
/// Transaction fee in basis points (default: 77 = 0.77%).
|
|
94
|
+
pub tx_fee_bps: u64,
|
|
95
|
+
/// Swap fee in basis points (default: 30 = 0.30%).
|
|
96
|
+
pub swap_fee_bps: u64,
|
|
97
|
+
/// Address that accumulates collected fees.
|
|
98
|
+
pub fee_recipient: Address,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
impl Default for FeeSchedule {
|
|
102
|
+
fn default() -> Self {
|
|
103
|
+
Self {
|
|
104
|
+
tx_fee_bps: DEFAULT_TX_FEE_BPS,
|
|
105
|
+
swap_fee_bps: DEFAULT_SWAP_FEE_BPS,
|
|
106
|
+
fee_recipient: PLACEHOLDER_FEE_RECIPIENT,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
impl FeeSchedule {
|
|
112
|
+
/// Creates a new [`FeeSchedule`] with explicit parameters.
|
|
113
|
+
pub fn new(tx_fee_bps: u64, swap_fee_bps: u64, fee_recipient: Address) -> Self {
|
|
114
|
+
Self {
|
|
115
|
+
tx_fee_bps,
|
|
116
|
+
swap_fee_bps,
|
|
117
|
+
fee_recipient,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// Returns the effective basis-point rate for the given `fee_type`.
|
|
122
|
+
fn bps_for(&self, fee_type: &FeeType) -> u64 {
|
|
123
|
+
match fee_type {
|
|
124
|
+
FeeType::Transaction => self.tx_fee_bps,
|
|
125
|
+
FeeType::Swap => self.swap_fee_bps,
|
|
126
|
+
FeeType::Custom(bps) => *bps,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// Calculates a fee for `amount` and the given `fee_type`.
|
|
131
|
+
///
|
|
132
|
+
/// # Errors
|
|
133
|
+
///
|
|
134
|
+
/// - [`FeeError::InvalidBps`] — rate exceeds 10 000.
|
|
135
|
+
/// - [`FeeError::Token`] — arithmetic overflow.
|
|
136
|
+
/// - [`FeeError::FeeExceedsAmount`] — fee ≥ gross.
|
|
137
|
+
pub fn calculate(
|
|
138
|
+
&self,
|
|
139
|
+
amount: TokenAmount,
|
|
140
|
+
fee_type: FeeType,
|
|
141
|
+
) -> Result<FeeCalculation, FeeError> {
|
|
142
|
+
let bps = self.bps_for(&fee_type);
|
|
143
|
+
if bps > 10_000 {
|
|
144
|
+
return Err(FeeError::InvalidBps(bps));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let fee_amount = amount
|
|
148
|
+
.checked_mul_bps(bps)
|
|
149
|
+
.ok_or_else(|| FeeError::Token("overflow in checked_mul_bps".to_string()))?;
|
|
150
|
+
|
|
151
|
+
let net_amount = amount
|
|
152
|
+
.sub(&fee_amount)
|
|
153
|
+
.map_err(|_| FeeError::FeeExceedsAmount { fee_bps: bps })?;
|
|
154
|
+
|
|
155
|
+
Ok(FeeCalculation {
|
|
156
|
+
gross_amount: amount,
|
|
157
|
+
fee_amount,
|
|
158
|
+
net_amount,
|
|
159
|
+
fee_recipient: self.fee_recipient,
|
|
160
|
+
fee_type,
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Tests
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
#[cfg(test)]
|
|
170
|
+
mod tests {
|
|
171
|
+
use super::*;
|
|
172
|
+
use alloy_primitives::Address;
|
|
173
|
+
|
|
174
|
+
fn usdc(amount: f64) -> TokenAmount {
|
|
175
|
+
TokenAmount::from_human(amount, 6)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
fn eth(amount: f64) -> TokenAmount {
|
|
179
|
+
TokenAmount::from_human(amount, 18)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#[test]
|
|
183
|
+
fn default_fee_schedule_values() {
|
|
184
|
+
let s = FeeSchedule::default();
|
|
185
|
+
assert_eq!(s.tx_fee_bps, 77);
|
|
186
|
+
assert_eq!(s.swap_fee_bps, 30);
|
|
187
|
+
assert_eq!(s.fee_recipient, Address::ZERO);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#[test]
|
|
191
|
+
fn tx_fee_usdc_1000() {
|
|
192
|
+
let s = FeeSchedule::default();
|
|
193
|
+
let calc = s.calculate(usdc(1000.0), FeeType::Transaction).unwrap();
|
|
194
|
+
// 1000 USDC × 77/10000 = 7.7 USDC
|
|
195
|
+
assert!((calc.fee_amount.to_human() - 7.7).abs() < 0.000_001);
|
|
196
|
+
assert!((calc.net_amount.to_human() - 992.3).abs() < 0.000_001);
|
|
197
|
+
assert_eq!(calc.fee_type, FeeType::Transaction);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
#[test]
|
|
201
|
+
fn tx_fee_usdc_1() {
|
|
202
|
+
let s = FeeSchedule::default();
|
|
203
|
+
let calc = s.calculate(usdc(1.0), FeeType::Transaction).unwrap();
|
|
204
|
+
// 1 USDC × 77/10000 = 0.0077 USDC
|
|
205
|
+
assert!((calc.fee_amount.to_human() - 0.0077).abs() < 0.000_001);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
#[test]
|
|
209
|
+
fn swap_fee_usdc_1000() {
|
|
210
|
+
let s = FeeSchedule::default();
|
|
211
|
+
let calc = s.calculate(usdc(1000.0), FeeType::Swap).unwrap();
|
|
212
|
+
// 1000 USDC × 30/10000 = 3 USDC
|
|
213
|
+
assert!((calc.fee_amount.to_human() - 3.0).abs() < 0.000_001);
|
|
214
|
+
assert_eq!(calc.fee_type, FeeType::Swap);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
#[test]
|
|
218
|
+
fn swap_fee_custom_50bps() {
|
|
219
|
+
let s = FeeSchedule::new(30, 50, Address::ZERO);
|
|
220
|
+
let calc = s.calculate(usdc(200.0), FeeType::Swap).unwrap();
|
|
221
|
+
// 200 USDC × 50/10000 = 1 USDC
|
|
222
|
+
assert!((calc.fee_amount.to_human() - 1.0).abs() < 0.000_001);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
#[test]
|
|
226
|
+
fn tx_fee_eth_1() {
|
|
227
|
+
let s = FeeSchedule::default();
|
|
228
|
+
let calc = s.calculate(eth(1.0), FeeType::Transaction).unwrap();
|
|
229
|
+
// 1 ETH × 77/10000 = 0.0077 ETH
|
|
230
|
+
assert!((calc.fee_amount.to_human() - 0.0077).abs() < 0.000_001);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
#[test]
|
|
234
|
+
fn swap_fee_eth_10() {
|
|
235
|
+
let s = FeeSchedule::default();
|
|
236
|
+
let calc = s.calculate(eth(10.0), FeeType::Swap).unwrap();
|
|
237
|
+
// 10 ETH × 30/10000 = 0.03 ETH
|
|
238
|
+
assert!((calc.fee_amount.to_human() - 0.03).abs() < 0.000_001);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#[test]
|
|
242
|
+
fn zero_amount_returns_zero_fee() {
|
|
243
|
+
let s = FeeSchedule::default();
|
|
244
|
+
let calc = s.calculate(usdc(0.0), FeeType::Transaction).unwrap();
|
|
245
|
+
assert!(calc.fee_amount.is_zero());
|
|
246
|
+
assert!(calc.net_amount.is_zero());
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#[test]
|
|
250
|
+
fn custom_fee_type_100bps() {
|
|
251
|
+
let s = FeeSchedule::default();
|
|
252
|
+
let calc = s.calculate(usdc(100.0), FeeType::Custom(100)).unwrap();
|
|
253
|
+
// 100 USDC × 100/10000 = 1 USDC
|
|
254
|
+
assert!((calc.fee_amount.to_human() - 1.0).abs() < 0.000_001);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
#[test]
|
|
258
|
+
fn fee_recipient_propagated() {
|
|
259
|
+
use alloy_primitives::address;
|
|
260
|
+
let recipient = address!("d8dA6BF26964aF9D7eEd9e03E53415D37aA96045");
|
|
261
|
+
let s = FeeSchedule::new(77, 30, recipient);
|
|
262
|
+
let calc = s.calculate(usdc(500.0), FeeType::Transaction).unwrap();
|
|
263
|
+
assert_eq!(calc.fee_recipient, recipient);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
#[test]
|
|
267
|
+
fn gross_equals_fee_plus_net() {
|
|
268
|
+
let s = FeeSchedule::default();
|
|
269
|
+
let gross = usdc(123.456789);
|
|
270
|
+
let calc = s.calculate(gross.clone(), FeeType::Swap).unwrap();
|
|
271
|
+
let reconstructed = calc.fee_amount.add(&calc.net_amount).unwrap();
|
|
272
|
+
assert_eq!(reconstructed, calc.gross_amount);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
#[test]
|
|
276
|
+
fn bps_over_10000_is_rejected() {
|
|
277
|
+
let s = FeeSchedule::new(10_001, 30, Address::ZERO);
|
|
278
|
+
let err = s.calculate(usdc(1.0), FeeType::Transaction).unwrap_err();
|
|
279
|
+
assert_eq!(err, FeeError::InvalidBps(10_001));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "clawpowers-index"
|
|
3
|
+
version.workspace = true
|
|
4
|
+
edition.workspace = true
|
|
5
|
+
license.workspace = true
|
|
6
|
+
|
|
7
|
+
[dependencies]
|
|
8
|
+
serde = { workspace = true }
|
|
9
|
+
serde_json = { workspace = true }
|
|
10
|
+
thiserror = { workspace = true }
|
|
11
|
+
tracing = { workspace = true }
|
|
12
|
+
uuid = { workspace = true }
|
|
13
|
+
clawpowers-compression = { path = "../compression" }
|
|
14
|
+
|
|
15
|
+
[dev-dependencies]
|
|
16
|
+
rand = { workspace = true }
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
//! Vector index adapter for TurboMemory.
|
|
2
|
+
//!
|
|
3
|
+
//! Exposes a [`VectorIndex`] trait and an [`InMemoryIndex`] implementation
|
|
4
|
+
//! that stores compressed vectors via [`TurboCompressor`] and retrieves
|
|
5
|
+
//! nearest neighbours using brute-force cosine similarity.
|
|
6
|
+
|
|
7
|
+
use clawpowers_compression::{CompressedVector, CompressionConfig, TurboCompressor};
|
|
8
|
+
use thiserror::Error;
|
|
9
|
+
use uuid::Uuid;
|
|
10
|
+
|
|
11
|
+
/// Errors produced by the index.
|
|
12
|
+
#[derive(Debug, Error)]
|
|
13
|
+
pub enum IndexError {
|
|
14
|
+
/// The query vector has a different dimensionality than the index.
|
|
15
|
+
#[error("dimension mismatch: expected {expected}, got {got}")]
|
|
16
|
+
DimensionMismatch {
|
|
17
|
+
/// Expected dimensionality.
|
|
18
|
+
expected: usize,
|
|
19
|
+
/// Provided dimensionality.
|
|
20
|
+
got: usize,
|
|
21
|
+
},
|
|
22
|
+
/// The requested number of results is zero.
|
|
23
|
+
#[error("top_k must be > 0")]
|
|
24
|
+
ZeroTopK,
|
|
25
|
+
/// Underlying compression error.
|
|
26
|
+
#[error("compression error: {0}")]
|
|
27
|
+
Compression(#[from] clawpowers_compression::CompressionError),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// A shorthand result type for [`IndexError`].
|
|
31
|
+
pub type Result<T> = std::result::Result<T, IndexError>;
|
|
32
|
+
|
|
33
|
+
/// A single ranked search result.
|
|
34
|
+
#[derive(Debug, Clone)]
|
|
35
|
+
pub struct SearchResult {
|
|
36
|
+
/// Identifier of the matching vector.
|
|
37
|
+
pub id: Uuid,
|
|
38
|
+
/// Similarity score (cosine similarity, higher is more similar).
|
|
39
|
+
pub score: f32,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// Trait for vector stores used by TurboMemory.
|
|
43
|
+
pub trait VectorIndex {
|
|
44
|
+
/// Insert a vector under `id`.
|
|
45
|
+
fn insert(&mut self, id: Uuid, vector: Vec<f32>) -> Result<()>;
|
|
46
|
+
/// Return the `top_k` most similar vectors to `query`, ranked descending by score.
|
|
47
|
+
fn search(&self, query: &[f32], top_k: usize) -> Result<Vec<SearchResult>>;
|
|
48
|
+
/// Remove the vector with `id`. Returns `true` if it was present.
|
|
49
|
+
fn remove(&mut self, id: &Uuid) -> Result<bool>;
|
|
50
|
+
/// Number of vectors currently in the index.
|
|
51
|
+
fn len(&self) -> usize;
|
|
52
|
+
/// Return `true` if the index contains no vectors.
|
|
53
|
+
fn is_empty(&self) -> bool {
|
|
54
|
+
self.len() == 0
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
struct Entry {
|
|
59
|
+
id: Uuid,
|
|
60
|
+
/// Stored for potential future fast-path approximate distance calculations.
|
|
61
|
+
#[allow(dead_code)]
|
|
62
|
+
compressed: CompressedVector,
|
|
63
|
+
/// Original vector used for exact cosine similarity.
|
|
64
|
+
original: Vec<f32>,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// In-memory brute-force vector index backed by TurboQuant compression.
|
|
68
|
+
///
|
|
69
|
+
/// Suitable for up to ~100 K vectors.
|
|
70
|
+
pub struct InMemoryIndex {
|
|
71
|
+
compressor: TurboCompressor,
|
|
72
|
+
entries: Vec<Entry>,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
impl InMemoryIndex {
|
|
76
|
+
/// Create a new index with the given compression configuration.
|
|
77
|
+
pub fn new(config: CompressionConfig) -> Self {
|
|
78
|
+
let compressor = TurboCompressor::new(config);
|
|
79
|
+
Self {
|
|
80
|
+
compressor,
|
|
81
|
+
entries: Vec::new(),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// Create an index with sensible defaults for `dimensions`.
|
|
86
|
+
pub fn with_dimensions(dimensions: usize) -> Self {
|
|
87
|
+
Self::new(CompressionConfig {
|
|
88
|
+
dimensions,
|
|
89
|
+
quantization_bits: 8,
|
|
90
|
+
rotation_seed: 0xDEAD_BEEF_CAFE_1234,
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
impl VectorIndex for InMemoryIndex {
|
|
96
|
+
fn insert(&mut self, id: Uuid, vector: Vec<f32>) -> Result<()> {
|
|
97
|
+
let compressed = self.compressor.compress(&vector)?;
|
|
98
|
+
self.entries.push(Entry {
|
|
99
|
+
id,
|
|
100
|
+
compressed,
|
|
101
|
+
original: vector,
|
|
102
|
+
});
|
|
103
|
+
Ok(())
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
fn search(&self, query: &[f32], top_k: usize) -> Result<Vec<SearchResult>> {
|
|
107
|
+
if top_k == 0 {
|
|
108
|
+
return Err(IndexError::ZeroTopK);
|
|
109
|
+
}
|
|
110
|
+
let dim = self.compressor.config.dimensions;
|
|
111
|
+
if query.len() != dim {
|
|
112
|
+
return Err(IndexError::DimensionMismatch {
|
|
113
|
+
expected: dim,
|
|
114
|
+
got: query.len(),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
let mut scored: Vec<(f32, &Entry)> = self
|
|
118
|
+
.entries
|
|
119
|
+
.iter()
|
|
120
|
+
.map(|e| (cosine_similarity(query, &e.original), e))
|
|
121
|
+
.collect();
|
|
122
|
+
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
|
|
123
|
+
Ok(scored
|
|
124
|
+
.into_iter()
|
|
125
|
+
.take(top_k)
|
|
126
|
+
.map(|(score, e)| SearchResult { id: e.id, score })
|
|
127
|
+
.collect())
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
fn remove(&mut self, id: &Uuid) -> Result<bool> {
|
|
131
|
+
let before = self.entries.len();
|
|
132
|
+
self.entries.retain(|e| &e.id != id);
|
|
133
|
+
Ok(self.entries.len() < before)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
fn len(&self) -> usize {
|
|
137
|
+
self.entries.len()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
|
142
|
+
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
|
|
143
|
+
let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
|
|
144
|
+
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
|
|
145
|
+
if na < f32::EPSILON || nb < f32::EPSILON {
|
|
146
|
+
0.0
|
|
147
|
+
} else {
|
|
148
|
+
dot / (na * nb)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#[cfg(test)]
|
|
153
|
+
mod tests {
|
|
154
|
+
use super::*;
|
|
155
|
+
|
|
156
|
+
const DIM: usize = 32;
|
|
157
|
+
|
|
158
|
+
fn make_index() -> InMemoryIndex {
|
|
159
|
+
InMemoryIndex::with_dimensions(DIM)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
fn unit_vec(hot: usize) -> Vec<f32> {
|
|
163
|
+
let mut v = vec![0.0_f32; DIM];
|
|
164
|
+
v[hot % DIM] = 1.0;
|
|
165
|
+
v
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
fn rand_vec(seed: u64) -> Vec<f32> {
|
|
169
|
+
let mut x = seed;
|
|
170
|
+
(0..DIM)
|
|
171
|
+
.map(|_| {
|
|
172
|
+
x = x
|
|
173
|
+
.wrapping_mul(6_364_136_223_846_793_005)
|
|
174
|
+
.wrapping_add(1_442_695_040_888_963_407);
|
|
175
|
+
((x >> 33) as f32 / u32::MAX as f32) * 2.0 - 1.0
|
|
176
|
+
})
|
|
177
|
+
.collect()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#[test]
|
|
181
|
+
fn test_insert_increases_len() {
|
|
182
|
+
let mut idx = make_index();
|
|
183
|
+
assert_eq!(idx.len(), 0);
|
|
184
|
+
idx.insert(Uuid::new_v4(), rand_vec(1)).expect("insert");
|
|
185
|
+
assert_eq!(idx.len(), 1);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
#[test]
|
|
189
|
+
fn test_empty_index_search_returns_empty() {
|
|
190
|
+
let idx = make_index();
|
|
191
|
+
assert!(idx.search(&rand_vec(2), 5).expect("search").is_empty());
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
#[test]
|
|
195
|
+
fn test_is_empty() {
|
|
196
|
+
let mut idx = make_index();
|
|
197
|
+
assert!(idx.is_empty());
|
|
198
|
+
idx.insert(Uuid::new_v4(), rand_vec(3)).expect("insert");
|
|
199
|
+
assert!(!idx.is_empty());
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
#[test]
|
|
203
|
+
fn test_search_returns_nearest_neighbour() {
|
|
204
|
+
let mut idx = make_index();
|
|
205
|
+
let query = unit_vec(0);
|
|
206
|
+
let id_match = Uuid::new_v4();
|
|
207
|
+
let id_other = Uuid::new_v4();
|
|
208
|
+
idx.insert(id_match, unit_vec(0)).expect("insert match");
|
|
209
|
+
idx.insert(id_other, unit_vec(1)).expect("insert other");
|
|
210
|
+
let results = idx.search(&query, 1).expect("search");
|
|
211
|
+
assert_eq!(results.len(), 1);
|
|
212
|
+
assert_eq!(results[0].id, id_match);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#[test]
|
|
216
|
+
fn test_search_results_ordered_by_score_descending() {
|
|
217
|
+
let mut idx = make_index();
|
|
218
|
+
let query = rand_vec(42);
|
|
219
|
+
for i in 0..5 {
|
|
220
|
+
idx.insert(Uuid::new_v4(), rand_vec(i + 100))
|
|
221
|
+
.expect("insert");
|
|
222
|
+
}
|
|
223
|
+
let results = idx.search(&query, 5).expect("search");
|
|
224
|
+
for w in results.windows(2) {
|
|
225
|
+
assert!(w[0].score >= w[1].score, "{} < {}", w[0].score, w[1].score);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
#[test]
|
|
230
|
+
fn test_search_top_k_limits_results() {
|
|
231
|
+
let mut idx = make_index();
|
|
232
|
+
for i in 0..10 {
|
|
233
|
+
idx.insert(Uuid::new_v4(), rand_vec(i)).expect("insert");
|
|
234
|
+
}
|
|
235
|
+
assert_eq!(idx.search(&rand_vec(99), 3).expect("search").len(), 3);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
#[test]
|
|
239
|
+
fn test_remove_existing_returns_true() {
|
|
240
|
+
let mut idx = make_index();
|
|
241
|
+
let id = Uuid::new_v4();
|
|
242
|
+
idx.insert(id, rand_vec(5)).expect("insert");
|
|
243
|
+
assert!(idx.remove(&id).expect("remove"));
|
|
244
|
+
assert_eq!(idx.len(), 0);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#[test]
|
|
248
|
+
fn test_remove_nonexistent_returns_false() {
|
|
249
|
+
let mut idx = make_index();
|
|
250
|
+
assert!(!idx.remove(&Uuid::new_v4()).expect("remove"));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#[test]
|
|
254
|
+
fn test_removed_vector_not_in_results() {
|
|
255
|
+
let mut idx = make_index();
|
|
256
|
+
let query = unit_vec(0);
|
|
257
|
+
let id = Uuid::new_v4();
|
|
258
|
+
idx.insert(id, unit_vec(0)).expect("insert");
|
|
259
|
+
idx.remove(&id).expect("remove");
|
|
260
|
+
assert!(
|
|
261
|
+
!idx.search(&query, 10)
|
|
262
|
+
.expect("search")
|
|
263
|
+
.iter()
|
|
264
|
+
.any(|r| r.id == id)
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
#[test]
|
|
269
|
+
fn test_zero_top_k_errors() {
|
|
270
|
+
let mut idx = make_index();
|
|
271
|
+
let _ = idx.insert(Uuid::new_v4(), rand_vec(6));
|
|
272
|
+
assert!(matches!(
|
|
273
|
+
idx.search(&rand_vec(7), 0).expect_err("e"),
|
|
274
|
+
IndexError::ZeroTopK
|
|
275
|
+
));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "clawpowers-policy"
|
|
3
|
+
version.workspace = true
|
|
4
|
+
edition.workspace = true
|
|
5
|
+
license.workspace = true
|
|
6
|
+
|
|
7
|
+
[dependencies]
|
|
8
|
+
serde = { workspace = true }
|
|
9
|
+
serde_json = { workspace = true }
|
|
10
|
+
thiserror = { workspace = true }
|
|
11
|
+
tracing = { workspace = true }
|
|
12
|
+
chrono = { workspace = true }
|
|
13
|
+
alloy-primitives = { workspace = true }
|
|
14
|
+
clawpowers-tokens = { path = "../tokens" }
|
|
15
|
+
|
|
16
|
+
[dev-dependencies]
|
|
17
|
+
tokio = { workspace = true }
|