pond/adapter/extract.rs
1//! Adapter seam primitives: `Source`, `Extracted<T>`, and the `extract_*`
2//! family.
3//!
4//! These types are the load-bearing contract for pond's adapter ecosystem
5//! (CLAUDE.md "Adapter seam"). They make ONE thing impossible to express:
6//! synthesizing a value the source data did not carry. The schema fields
7//! that hold "did the source say this?" data are typed as
8//! `Option<Extracted<T>>`, and the only way to construct an
9//! `Extracted<T>` is to extract it from a `Source` via one of the
10//! `extract_*` functions in this module. Adapters cannot produce a
11//! sentinel ("unknown", "function", "") through any combination of trait
12//! methods, conversions, or struct literals - the seal is module-private.
13//!
14//! Transport-agnostic by design: `Source` is a tiny trait that adapter
15//! authors implement for their own row type, regardless of how the row
16//! arrived (JSONL file, HTTP response body, WebSocket frame, queue
17//! payload, database row). pond ships `impl Source for serde_json::Value`
18//! for JSON-flavored adapters; the trait carries no transport assumptions.
19//!
20//! See spec.md#model-no-synthesis, spec.md#model-schema-honesty, and spec.md#model-lossless-projection for the underlying principles.
21
22use std::sync::atomic::{AtomicU64, Ordering};
23
24use serde_json::Value;
25
26/// A value that was pulled from real source data. The wrapper is opaque to
27/// adapter code: there is no public constructor, no `From<T>`, no
28/// `Default`, no consuming `into_inner`. Read-only access is via `Deref`
29/// so consumers and storage code can use the inner value freely without
30/// being able to forge new instances.
31///
32/// The only path that produces an `Extracted<T>` is the
33/// [`wrap`] function below, which is module-private and called solely by
34/// the `extract_*` helpers in this file. An adapter that wants to put a
35/// value into a schema field has to obtain it from a `Source` via one of
36/// those helpers; no other path exists.
37#[derive(Debug, Clone, PartialEq, Eq, Hash)]
38pub struct Extracted<T>(T);
39
40impl<T> Extracted<T> {
41 /// Borrow the inner value.
42 #[allow(clippy::should_implement_trait)]
43 pub fn as_ref(&self) -> &T {
44 &self.0
45 }
46
47 /// Test-only constructor. Lets unit-test code build Part / Message
48 /// literals directly without routing through a `Source` (which would
49 /// force every test to stand up a synthetic JSON row). Compiled out of
50 /// release builds via `#[cfg(test)]`, so production adapter code
51 /// genuinely cannot reach this even from sibling modules.
52 #[cfg(test)]
53 pub fn from_test_value(value: T) -> Self {
54 wrap(value)
55 }
56
57 /// Crate-internal constructor for the storage decode path. Lance
58 /// stores values that originally came from a `Source` extraction; on
59 /// read-back we need to rewrap them. NOT for adapter use - adapter
60 /// code MUST go through `extract_*`. The `pub(crate)` visibility
61 /// limits this to pond's own infrastructure (notably the
62 /// `message_from_batch` / `part_from_batch` decoders in
63 /// `src/sessions.rs`). Code review checks that no adapter module
64 /// reaches for this.
65 pub(crate) fn from_stored(value: T) -> Self {
66 wrap(value)
67 }
68}
69
70impl<T> std::ops::Deref for Extracted<T> {
71 type Target = T;
72
73 fn deref(&self) -> &T {
74 &self.0
75 }
76}
77
78/// Module-private constructor. The single place in pond's codebase that
79/// can wrap a raw `T` into `Extracted<T>`. Visibility is bounded to this
80/// module so adapter code (which lives in sibling modules under
81/// `src/adapter/`) cannot reach it - that is the seal that makes the
82/// "no synthesized values" invariant compile-enforced.
83fn wrap<T>(value: T) -> Extracted<T> {
84 Extracted(value)
85}
86
87// Arrow `Utf8` columns address with an `i32` offset buffer: no stored text
88// value may approach `i32::MAX`. The seam caps every extracted value at
89// `LEAF_CAP`, truncating an oversized leaf to a head-preserving marker
90// (spec.md#adapter-bounded-values).
91pub(crate) const LEAF_CAP: usize = 10 * 1024 * 1024;
92
93static TRUNCATED_VALUES: AtomicU64 = AtomicU64::new(0);
94
95pub(crate) fn truncated_values_count() -> u64 {
96 TRUNCATED_VALUES.load(Ordering::Relaxed)
97}
98
99fn record_truncation(original_bytes: usize) {
100 TRUNCATED_VALUES.fetch_add(1, Ordering::Relaxed);
101 // Per-occurrence detail at debug; the operator-facing signal is the
102 // `truncated_values` count in the sync summary, always visible.
103 tracing::debug!(
104 original_bytes,
105 cap_bytes = LEAF_CAP,
106 "value exceeded the seam leaf cap; truncated to a marked sentinel"
107 );
108}
109
110fn truncation_marker(original_bytes: usize) -> String {
111 format!("<pond:truncated {original_bytes} bytes>")
112}
113
114/// Truncate an over-cap value to its head-preserving sentinel: a UTF-8 prefix
115/// of `head` plus the `<pond:truncated N bytes>` marker, the whole staying
116/// within `LEAF_CAP`. `original` is the value's true byte length, which may
117/// exceed `head.len()` when the caller streamed and discarded the tail. The
118/// sole definition of the truncation shape (spec.md#adapter-bounded-values). Caller
119/// guarantees `head.len() > LEAF_CAP`.
120pub(crate) fn truncate_to_marker(head: &[u8], original: usize) -> String {
121 let marker = truncation_marker(original);
122 let mut end = LEAF_CAP.saturating_sub(marker.len());
123 while end > 0 && head[end] & 0xC0 == 0x80 {
124 end -= 1;
125 }
126 let mut capped = String::from_utf8_lossy(&head[..end]).into_owned();
127 capped.push_str(&marker);
128 record_truncation(original);
129 capped
130}
131
132pub(crate) fn bound_str(s: &mut String) -> bool {
133 if s.len() <= LEAF_CAP {
134 return false;
135 }
136 *s = truncate_to_marker(s.as_bytes(), s.len());
137 true
138}
139
140pub(crate) fn bound_value(value: &mut Value) {
141 match value {
142 Value::String(s) => {
143 bound_str(s);
144 }
145 Value::Array(items) => items.iter_mut().for_each(bound_value),
146 Value::Object(map) => map.values_mut().for_each(bound_value),
147 Value::Null | Value::Bool(_) | Value::Number(_) => {}
148 }
149}
150
151pub fn extract_raw_record(row: &Value) -> Value {
152 let mut bounded = row.clone();
153 bound_value(&mut bounded);
154 bounded
155}
156
157/// Parse `text` as JSON, or preserve it losslessly as a JSON string when it
158/// is not valid JSON. The lossless parse-or-preserve idiom for source columns
159/// that usually hold serialized JSON but may hold plain text (e.g. a tool
160/// call's `arguments`): the raw text survives either way, so no data is
161/// invented or dropped.
162pub(crate) fn json_or_string(text: &str) -> Value {
163 serde_json::from_str(text).unwrap_or_else(|_| Value::String(text.to_owned()))
164}
165
166//
167// Round-tripping `Extracted<T>` through serde is required because
168// `PartKind::Text { text: Option<Extracted<String>> }` (and friends) are
169// serialized to/from JSON for the wire protocol and for Lance's
170// `variant_data` column. Encoding is just the inner value; decoding
171// rewraps the inner value via `wrap`. From an adapter author's
172// perspective serde Deserialize is part of pond, not part of the seam
173// they get to call from their adapter code - so this does not loosen the
174// non-synthesis guarantee for adapters. (Storage decoding happens in
175// `src/sessions.rs::part_from_batch`, not in any adapter.)
176
177impl<T: serde::Serialize> serde::Serialize for Extracted<T> {
178 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
179 self.0.serialize(serializer)
180 }
181}
182
183impl<'de, T: serde::Deserialize<'de>> serde::Deserialize<'de> for Extracted<T> {
184 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
185 T::deserialize(deserializer).map(wrap)
186 }
187}
188
189/// One row of source data, abstracted across transports. Adapter authors
190/// implement this for whatever type carries one event from their source:
191///
192/// - JSON-flavored adapters (claude-code, codex-cli, ...): pond ships
193/// `impl Source for serde_json::Value` below, so the adapter just
194/// reuses it.
195/// - Struct-flavored adapters (nanoclaw API responses, managed-agent
196/// stream frames, database rows): the adapter defines a row struct and
197/// `impl Source for MyRow` with whatever lookup makes sense for that
198/// format.
199///
200/// The trait is intentionally minimal - just the four primitives
201/// `extract_*` needs - so it ports cleanly to non-JSON sources.
202pub trait Source {
203 /// String field by key. `None` when the field is absent OR present
204 /// but not a string. Adapters that need to distinguish "missing" from
205 /// "wrong type" should branch on `value_field` first.
206 fn str_field(&self, key: &str) -> Option<&str>;
207
208 /// Boolean field by key. `None` for missing-or-wrong-type, same as
209 /// `str_field`.
210 fn bool_field(&self, key: &str) -> Option<bool>;
211
212 /// Raw structured value by key (when the field is a nested object,
213 /// array, or any non-primitive shape pond stores as `Value`). `None`
214 /// for missing.
215 fn value_field(&self, key: &str) -> Option<&Value>;
216
217 /// Nested source view by key. Lets `extract_*` walk into sub-objects
218 /// without the adapter manually unwrapping intermediate layers.
219 /// `None` when the key is absent or not an object.
220 fn nested(&self, key: &str) -> Option<&dyn Source>;
221
222 /// The source itself viewed as a primitive string, when it IS one
223 /// (e.g. `Value::String("hi")`). `None` for object / array / number
224 /// shapes. Used by adapters when the "row" they hold is itself the
225 /// string they want to extract (claude-code's
226 /// `("user", Value::String(text))` shape).
227 fn as_str(&self) -> Option<&str> {
228 None
229 }
230
231 /// Lossless string encoding of the whole source. Returned by
232 /// `extract_compact_repr` when an adapter wants to preserve a row's
233 /// bytes via a faithful encoding fallback (e.g. an unknown
234 /// `assistant_part` subtype that we still want to keep as text).
235 /// This is NOT synthesis (the encoding preserves what was there),
236 /// but going through the seam means storage-layer code can rely on
237 /// every stored value having flowed through a `Source`. For JSON
238 /// sources the default impl is compact `serde_json::to_string`.
239 fn compact_repr(&self) -> String;
240}
241
242//
243// These are the ONLY public producers of `Option<Extracted<T>>`. Anything
244// else that needs to put a value into a schema field has to go through
245// one of these (directly or via composition), which means it has to come
246// from a `Source` - i.e. from real source data.
247
248/// Extract a `String` field. `None` when the source did not carry it.
249pub fn extract_str(source: &dyn Source, key: &str) -> Option<Extracted<String>> {
250 source.str_field(key).map(|s| {
251 let mut owned = s.to_owned();
252 bound_str(&mut owned);
253 wrap(owned)
254 })
255}
256
257/// Extract a `bool` field. `None` when the source did not carry it.
258pub fn extract_bool(source: &dyn Source, key: &str) -> Option<Extracted<bool>> {
259 source.bool_field(key).map(wrap)
260}
261
262/// Extract a structured `Value` field. `None` when the source did not
263/// carry it. Used for fields like `params` / `result` where pond stores
264/// the raw JSON value alongside the canonical schema slots.
265pub fn extract_value(source: &dyn Source, key: &str) -> Option<Extracted<Value>> {
266 source.value_field(key).cloned().map(|mut value| {
267 bound_value(&mut value);
268 wrap(value)
269 })
270}
271
272/// The source itself, viewed as a primitive string. `None` when the
273/// source is not a primitive string shape. Composes with patterns where
274/// the "row" the adapter holds is the string (e.g. claude-code's
275/// `("user", Value::String(text))` content shape).
276pub fn extract_self_str(source: &dyn Source) -> Option<Extracted<String>> {
277 source.as_str().map(|s| {
278 let mut owned = s.to_owned();
279 bound_str(&mut owned);
280 wrap(owned)
281 })
282}
283
284/// Lossless compact-string encoding of the whole source. Always succeeds
285/// (returns an `Extracted<String>`, not an `Option`) because every
286/// `Source` knows how to serialize itself. Used for "preserve the row
287/// bytes when we don't have a richer canonical shape" cases - this is
288/// NOT synthesis: the encoded string is a faithful representation of
289/// data the source actually carried.
290pub fn extract_compact_repr(source: &dyn Source) -> Extracted<String> {
291 let mut repr = source.compact_repr();
292 bound_str(&mut repr);
293 wrap(repr)
294}
295
296//
297// The default implementation for JSON-flavored adapters. Sits at the
298// seam, not inside a specific adapter, so claude-code, codex-cli, and
299// any future JSON-based adapter share one implementation.
300
301impl Source for Value {
302 fn str_field(&self, key: &str) -> Option<&str> {
303 self.get(key).and_then(Value::as_str)
304 }
305
306 fn bool_field(&self, key: &str) -> Option<bool> {
307 self.get(key).and_then(Value::as_bool)
308 }
309
310 fn value_field(&self, key: &str) -> Option<&Value> {
311 self.get(key)
312 }
313
314 fn nested(&self, key: &str) -> Option<&dyn Source> {
315 self.get(key).map(|v| v as &dyn Source)
316 }
317
318 fn as_str(&self) -> Option<&str> {
319 Value::as_str(self)
320 }
321
322 fn compact_repr(&self) -> String {
323 // `to_string` on `Value` produces compact JSON. Falls back to an
324 // empty object encoding only if serialization itself fails, which
325 // is impossible for `Value` (it's a closed enum that always
326 // serializes), but the unwrap_or_default keeps the trait method
327 // total.
328 serde_json::to_string(self).unwrap_or_else(|_| "{}".to_owned())
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 #![allow(clippy::expect_used, clippy::unwrap_used)]
335
336 use super::*;
337 use serde_json::json;
338
339 #[test]
340 fn extract_str_pulls_present_field_and_wraps() {
341 let row = json!({"name": "Edit", "input": {"file": "/tmp/foo"}});
342 let extracted = extract_str(&row, "name").expect("name is present");
343 assert_eq!(&*extracted, "Edit", "Deref exposes the inner string");
344 assert_eq!(extracted.as_ref(), "Edit", "as_ref does too");
345 }
346
347 #[test]
348 fn extract_str_returns_none_when_field_absent() {
349 let row = json!({"name": "Edit"});
350 assert!(extract_str(&row, "missing").is_none());
351 }
352
353 #[test]
354 fn extract_str_returns_none_when_field_is_not_a_string() {
355 let row = json!({"name": 42});
356 assert!(
357 extract_str(&row, "name").is_none(),
358 "wrong-type fields surface as absence; adapters that care should branch on value_field first",
359 );
360 }
361
362 #[test]
363 fn extract_bool_and_extract_value_round_trip() {
364 let row = json!({
365 "is_error": true,
366 "input": {"k": "v"},
367 });
368 let is_error = extract_bool(&row, "is_error").expect("present");
369 assert!(*is_error);
370 let params = extract_value(&row, "input").expect("present");
371 assert_eq!(&*params, &json!({"k": "v"}));
372 }
373
374 #[test]
375 fn extracted_serde_round_trip_preserves_value() {
376 let extracted = extract_str(&json!({"k": "hello"}), "k").unwrap();
377 let encoded = serde_json::to_string(&extracted).unwrap();
378 assert_eq!(encoded, "\"hello\"");
379 let decoded: Extracted<String> = serde_json::from_str(&encoded).unwrap();
380 assert_eq!(&*decoded, "hello");
381 }
382
383 #[test]
384 fn source_impl_for_value_walks_nested_objects() {
385 let row = json!({"message": {"role": "user", "content": "hi"}});
386 let nested = row.nested("message").expect("message is an object");
387 assert_eq!(nested.str_field("role"), Some("user"));
388 assert_eq!(nested.str_field("content"), Some("hi"));
389 assert!(row.nested("missing").is_none());
390 }
391
392 #[test]
393 fn bound_value_caps_every_position_and_spares_good_leaves() {
394 let oversize = "x".repeat(LEAF_CAP + 100);
395 let mut value = json!({
396 "first": oversize,
397 "good_a": "ok",
398 "middle": oversize,
399 "good_b": "ok",
400 "nested": {"deep": oversize, "kept": "ok"},
401 "list": ["ok", oversize, "ok"],
402 "last": oversize,
403 });
404 bound_value(&mut value);
405
406 let marker = format!("{} bytes>", LEAF_CAP + 100);
407 let capped = |v: &Value| {
408 let text = v.as_str().expect("string leaf");
409 text.len() <= LEAF_CAP && text.ends_with(&marker)
410 };
411 let intact = |v: &Value| v.as_str() == Some("ok");
412 for path in [&value["first"], &value["middle"], &value["last"]] {
413 assert!(capped(path));
414 }
415 assert!(capped(&value["nested"]["deep"]));
416 assert!(capped(&value["list"][1]));
417 assert!(intact(&value["good_a"]));
418 assert!(intact(&value["good_b"]));
419 assert!(intact(&value["nested"]["kept"]));
420 assert!(intact(&value["list"][0]));
421 assert!(intact(&value["list"][2]));
422 }
423}