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.
@@ -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
+ }