goldenmatch-native 0.1.0__tar.gz
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.
- goldenmatch_native-0.1.0/PKG-INFO +50 -0
- goldenmatch_native-0.1.0/README.md +34 -0
- goldenmatch_native-0.1.0/fingerprint-core/Cargo.toml +23 -0
- goldenmatch_native-0.1.0/fingerprint-core/src/lib.rs +178 -0
- goldenmatch_native-0.1.0/native/Cargo.lock +1148 -0
- goldenmatch_native-0.1.0/native/Cargo.toml +41 -0
- goldenmatch_native-0.1.0/native/README.md +34 -0
- goldenmatch_native-0.1.0/native/rust-toolchain.toml +8 -0
- goldenmatch_native-0.1.0/native/src/cluster.rs +143 -0
- goldenmatch_native-0.1.0/native/src/featurize.rs +220 -0
- goldenmatch_native-0.1.0/native/src/hash.rs +105 -0
- goldenmatch_native-0.1.0/native/src/lib.rs +35 -0
- goldenmatch_native-0.1.0/native/src/pairs.rs +79 -0
- goldenmatch_native-0.1.0/native/src/score.rs +284 -0
- goldenmatch_native-0.1.0/pyproject.toml +39 -0
- goldenmatch_native-0.1.0/python/goldenmatch_native/__init__.py +14 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: goldenmatch-native
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Classifier: Programming Language :: Rust
|
|
5
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
6
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
7
|
+
Classifier: Intended Audience :: Developers
|
|
8
|
+
Classifier: Topic :: Scientific/Engineering
|
|
9
|
+
Summary: Optional native (Rust/PyO3) acceleration kernels for goldenmatch
|
|
10
|
+
Author-email: Ben Severn <ben@bensevern.dev>
|
|
11
|
+
License: MIT
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
14
|
+
Project-URL: Homepage, https://github.com/benseverndev-oss/goldenmatch
|
|
15
|
+
|
|
16
|
+
# goldenmatch-native
|
|
17
|
+
|
|
18
|
+
Optional native (Rust/PyO3) acceleration kernels for
|
|
19
|
+
[goldenmatch](https://github.com/benseverndev-oss/goldenmatch).
|
|
20
|
+
|
|
21
|
+
This is **not** a standalone package — it ships only the compiled `abi3`
|
|
22
|
+
extension that `goldenmatch` loads when present. Mirrors the
|
|
23
|
+
`polars` / `polars-runtime` split: the `goldenmatch` frontend stays a
|
|
24
|
+
pure-Python wheel, and the compiled runtime is distributed separately.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install "goldenmatch[native]" # frontend + this runtime
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Installing `goldenmatch` alone keeps the pure-Python paths; adding the
|
|
33
|
+
`native` extra pulls this package in and `goldenmatch` picks it up
|
|
34
|
+
automatically — no code change required. With the runtime present, the
|
|
35
|
+
auto-config planner routes simple/fast-box plans through the native Arrow
|
|
36
|
+
block-scorer (measured 1.7–3.7x faster at 1k–60k rows, identical clusters).
|
|
37
|
+
|
|
38
|
+
Set `GOLDENMATCH_PLANNER_BUCKET=0` to force the pure-Python scoring path even
|
|
39
|
+
with the runtime installed.
|
|
40
|
+
|
|
41
|
+
## What's inside
|
|
42
|
+
|
|
43
|
+
A single abi3 extension (`goldenmatch_native._native`) covering the gated
|
|
44
|
+
kernels: record fingerprinting, block scoring, pair generation, featurize, and
|
|
45
|
+
connected-components clustering. CPython 3.11+ on a per-platform wheel.
|
|
46
|
+
|
|
47
|
+
## License
|
|
48
|
+
|
|
49
|
+
MIT.
|
|
50
|
+
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# goldenmatch-native
|
|
2
|
+
|
|
3
|
+
Optional native (Rust/PyO3) acceleration kernels for
|
|
4
|
+
[goldenmatch](https://github.com/benseverndev-oss/goldenmatch).
|
|
5
|
+
|
|
6
|
+
This is **not** a standalone package — it ships only the compiled `abi3`
|
|
7
|
+
extension that `goldenmatch` loads when present. Mirrors the
|
|
8
|
+
`polars` / `polars-runtime` split: the `goldenmatch` frontend stays a
|
|
9
|
+
pure-Python wheel, and the compiled runtime is distributed separately.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install "goldenmatch[native]" # frontend + this runtime
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Installing `goldenmatch` alone keeps the pure-Python paths; adding the
|
|
18
|
+
`native` extra pulls this package in and `goldenmatch` picks it up
|
|
19
|
+
automatically — no code change required. With the runtime present, the
|
|
20
|
+
auto-config planner routes simple/fast-box plans through the native Arrow
|
|
21
|
+
block-scorer (measured 1.7–3.7x faster at 1k–60k rows, identical clusters).
|
|
22
|
+
|
|
23
|
+
Set `GOLDENMATCH_PLANNER_BUCKET=0` to force the pure-Python scoring path even
|
|
24
|
+
with the runtime installed.
|
|
25
|
+
|
|
26
|
+
## What's inside
|
|
27
|
+
|
|
28
|
+
A single abi3 extension (`goldenmatch_native._native`) covering the gated
|
|
29
|
+
kernels: record fingerprinting, block scoring, pair generation, featurize, and
|
|
30
|
+
connected-components clustering. CPython 3.11+ on a per-platform wheel.
|
|
31
|
+
|
|
32
|
+
## License
|
|
33
|
+
|
|
34
|
+
MIT.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Standalone workspace so this pyo3-free / pgrx-free core can be a path
|
|
2
|
+
# dependency of BOTH the `native` crate (its own workspace, extension-module)
|
|
3
|
+
# and the `postgres` crate (excluded from the bridge workspace, pgrx) without
|
|
4
|
+
# either workspace claiming it.
|
|
5
|
+
[workspace]
|
|
6
|
+
|
|
7
|
+
[package]
|
|
8
|
+
name = "goldenmatch-fingerprint-core"
|
|
9
|
+
version = "0.1.0"
|
|
10
|
+
edition = "2021"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = ["Ben Severn <benzsevern@gmail.com>"]
|
|
13
|
+
description = "Canonical record-fingerprint canonicalization (no pyo3, no pgrx) shared across surfaces"
|
|
14
|
+
|
|
15
|
+
[lib]
|
|
16
|
+
name = "goldenmatch_fingerprint_core"
|
|
17
|
+
|
|
18
|
+
[dependencies]
|
|
19
|
+
# Must match Python hashlib.sha256 byte-for-byte (see core/_hashing.py).
|
|
20
|
+
sha2 = "0.10"
|
|
21
|
+
# JSON parsing for non-Python callers (pgrx / DuckDB / C ABI). arbitrary_precision
|
|
22
|
+
# preserves integer literals exactly so big ints match the Python-str() path.
|
|
23
|
+
serde_json = { version = "1", features = ["arbitrary_precision"] }
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
//! Canonical record fingerprint — the cross-surface stable record-id hash.
|
|
2
|
+
//!
|
|
3
|
+
//! Pure Rust (no pyo3, no pgrx) so every surface can share ONE canonicalizer:
|
|
4
|
+
//! the `native` PyO3 extension wraps it for Python, the C ABI exposes it, and
|
|
5
|
+
//! the `postgres` pgrx extension calls it **directly** (no embedded CPython).
|
|
6
|
+
//!
|
|
7
|
+
//! The digest is SHA-256 (portable + fast everywhere; a hand-rolled hash would
|
|
8
|
+
//! just reimplement a tuned lib). The value of this crate is the
|
|
9
|
+
//! canonicalization, which CPython's `json.dumps(..., sort_keys=True,
|
|
10
|
+
//! default=str)` does NOT reproduce across languages.
|
|
11
|
+
//!
|
|
12
|
+
//! Spec — MUST match `goldenmatch.core._hashing._fingerprint_py` byte-for-byte:
|
|
13
|
+
//! drop `__`-prefixed fields; sort by name (UTF-8 byte order == code-point
|
|
14
|
+
//! order == Python `sorted`); append `name 0x1f TAG value 0x1e`; type-tagged
|
|
15
|
+
//! values (so int `1` != str `"1"` != `True`):
|
|
16
|
+
//!
|
|
17
|
+
//! ```text
|
|
18
|
+
//! null -> b'n' (no value bytes)
|
|
19
|
+
//! bool -> b'b' + b'1' / b'0'
|
|
20
|
+
//! int -> b'i' + base-10 ASCII (arbitrary precision)
|
|
21
|
+
//! float -> b'f' + 16 hex of IEEE-754 big-endian bits; -0.0 -> 0.0; NaN/Inf rejected
|
|
22
|
+
//! str -> b's' + raw UTF-8 bytes
|
|
23
|
+
//! bytes -> b'y' + raw bytes
|
|
24
|
+
//! ```
|
|
25
|
+
//!
|
|
26
|
+
//! then SHA-256 the buffer and return 64 lowercase hex chars.
|
|
27
|
+
use sha2::{Digest, Sha256};
|
|
28
|
+
|
|
29
|
+
const US: u8 = 0x1f; // unit separator: between a field name and its value
|
|
30
|
+
const RS: u8 = 0x1e; // record separator: end of one field
|
|
31
|
+
|
|
32
|
+
/// Type-tagged value the canonicalizer accepts. `Int` keeps the decimal string
|
|
33
|
+
/// (arbitrary precision); `Float` keeps the f64 (canonicalized via IEEE bits).
|
|
34
|
+
pub enum FpValue {
|
|
35
|
+
Null,
|
|
36
|
+
Bool(bool),
|
|
37
|
+
Int(String),
|
|
38
|
+
Float(f64),
|
|
39
|
+
Str(String),
|
|
40
|
+
Bytes(Vec<u8>),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fn to_hex(bytes: &[u8]) -> String {
|
|
44
|
+
let mut s = String::with_capacity(bytes.len() * 2);
|
|
45
|
+
for b in bytes {
|
|
46
|
+
s.push_str(&format!("{b:02x}"));
|
|
47
|
+
}
|
|
48
|
+
s
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// The canonicalization spec. The caller has already dropped `__`-prefixed
|
|
52
|
+
/// fields. Returns 64 lowercase hex chars, or an error string for a non-finite
|
|
53
|
+
/// float.
|
|
54
|
+
pub fn fingerprint_fields(mut fields: Vec<(String, FpValue)>) -> Result<String, String> {
|
|
55
|
+
fields.sort_by(|a, b| a.0.cmp(&b.0));
|
|
56
|
+
let mut buf: Vec<u8> = Vec::new();
|
|
57
|
+
for (name, value) in &fields {
|
|
58
|
+
buf.extend_from_slice(name.as_bytes());
|
|
59
|
+
buf.push(US);
|
|
60
|
+
match value {
|
|
61
|
+
FpValue::Null => buf.push(b'n'),
|
|
62
|
+
FpValue::Bool(b) => {
|
|
63
|
+
buf.push(b'b');
|
|
64
|
+
buf.push(if *b { b'1' } else { b'0' });
|
|
65
|
+
}
|
|
66
|
+
FpValue::Int(s) => {
|
|
67
|
+
buf.push(b'i');
|
|
68
|
+
buf.extend_from_slice(s.as_bytes());
|
|
69
|
+
}
|
|
70
|
+
FpValue::Float(x) => {
|
|
71
|
+
if !x.is_finite() {
|
|
72
|
+
return Err(format!(
|
|
73
|
+
"field {name:?}: non-finite float is not canonicalizable"
|
|
74
|
+
));
|
|
75
|
+
}
|
|
76
|
+
let norm = if *x == 0.0 { 0.0_f64 } else { *x }; // collapse -0.0
|
|
77
|
+
buf.push(b'f');
|
|
78
|
+
buf.extend_from_slice(to_hex(&norm.to_bits().to_be_bytes()).as_bytes());
|
|
79
|
+
}
|
|
80
|
+
FpValue::Str(s) => {
|
|
81
|
+
buf.push(b's');
|
|
82
|
+
buf.extend_from_slice(s.as_bytes());
|
|
83
|
+
}
|
|
84
|
+
FpValue::Bytes(b) => {
|
|
85
|
+
buf.push(b'y');
|
|
86
|
+
buf.extend_from_slice(b);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
buf.push(RS);
|
|
90
|
+
}
|
|
91
|
+
Ok(to_hex(&Sha256::digest(&buf)))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
fn json_to_fpvalue(v: &serde_json::Value) -> Result<FpValue, String> {
|
|
95
|
+
use serde_json::Value as J;
|
|
96
|
+
match v {
|
|
97
|
+
J::Null => Ok(FpValue::Null),
|
|
98
|
+
J::Bool(b) => Ok(FpValue::Bool(*b)),
|
|
99
|
+
J::String(s) => Ok(FpValue::Str(s.clone())),
|
|
100
|
+
J::Number(n) => {
|
|
101
|
+
// arbitrary_precision keeps the literal; an int has no '.'/'e'.
|
|
102
|
+
let lit = n.to_string();
|
|
103
|
+
if lit.contains('.') || lit.contains('e') || lit.contains('E') {
|
|
104
|
+
lit.parse::<f64>()
|
|
105
|
+
.map(FpValue::Float)
|
|
106
|
+
.map_err(|_| format!("unparseable number {lit}"))
|
|
107
|
+
} else {
|
|
108
|
+
Ok(FpValue::Int(lit))
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
J::Array(_) | J::Object(_) => {
|
|
112
|
+
Err("nested arrays/objects are not supported (v1 is primitive-only)".into())
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/// Canonical fingerprint of a record given as a JSON object string. Drops
|
|
118
|
+
/// `__`-prefixed keys. Returns 64 lowercase hex chars, or an error string for
|
|
119
|
+
/// invalid JSON, a non-object, an unsupported value, or a non-finite float.
|
|
120
|
+
pub fn fingerprint_json(s: &str) -> Result<String, String> {
|
|
121
|
+
let v: serde_json::Value = serde_json::from_str(s).map_err(|e| e.to_string())?;
|
|
122
|
+
let obj = v.as_object().ok_or("top-level JSON must be an object")?;
|
|
123
|
+
let mut fields: Vec<(String, FpValue)> = Vec::with_capacity(obj.len());
|
|
124
|
+
for (k, val) in obj {
|
|
125
|
+
if k.starts_with("__") {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
fields.push((k.clone(), json_to_fpvalue(val)?));
|
|
129
|
+
}
|
|
130
|
+
fingerprint_fields(fields)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#[cfg(test)]
|
|
134
|
+
mod tests {
|
|
135
|
+
use super::*;
|
|
136
|
+
|
|
137
|
+
// Pinned vectors computed from the canonical bytes (independent of impl),
|
|
138
|
+
// identical to tests/test_record_fingerprint.py::_PINNED.
|
|
139
|
+
#[test]
|
|
140
|
+
fn pinned_vectors() {
|
|
141
|
+
assert_eq!(
|
|
142
|
+
fingerprint_json("{}").unwrap(),
|
|
143
|
+
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
144
|
+
);
|
|
145
|
+
assert_eq!(
|
|
146
|
+
fingerprint_json(r#"{"a":"x"}"#).unwrap(),
|
|
147
|
+
"7381d5ba2dac5be0af49232a3209ab8d0dc2e4ed804a60ce533fdfe5254307e3"
|
|
148
|
+
);
|
|
149
|
+
assert_eq!(
|
|
150
|
+
fingerprint_json(r#"{"a":1}"#).unwrap(),
|
|
151
|
+
"b42e38730ddd9a099426dffa93926c03258ee2cd93f75204daa6f989af628206"
|
|
152
|
+
);
|
|
153
|
+
assert_eq!(
|
|
154
|
+
fingerprint_json(r#"{"n":1.5}"#).unwrap(),
|
|
155
|
+
"241b8cd11b575fd2b21e90b490f57fac54930f9a12124f23e284caa200c403a9"
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#[test]
|
|
160
|
+
fn drops_underscore_and_is_type_tagged() {
|
|
161
|
+
assert_eq!(
|
|
162
|
+
fingerprint_json(r#"{"a":1,"__row_id__":9}"#).unwrap(),
|
|
163
|
+
fingerprint_json(r#"{"a":1}"#).unwrap()
|
|
164
|
+
);
|
|
165
|
+
// int 1 != str "1"
|
|
166
|
+
assert_ne!(
|
|
167
|
+
fingerprint_json(r#"{"a":1}"#).unwrap(),
|
|
168
|
+
fingerprint_json(r#"{"a":"1"}"#).unwrap()
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#[test]
|
|
173
|
+
fn rejects_bad_input() {
|
|
174
|
+
assert!(fingerprint_json("not json").is_err());
|
|
175
|
+
assert!(fingerprint_json("[1,2,3]").is_err());
|
|
176
|
+
assert!(fingerprint_json(r#"{"a":[1]}"#).is_err());
|
|
177
|
+
}
|
|
178
|
+
}
|