diff --git a/.config/tracey/config.kdl b/.config/tracey/config.kdl new file mode 100644 index 0000000..f1740ce --- /dev/null +++ b/.config/tracey/config.kdl @@ -0,0 +1,12 @@ +spec { + name "picante" + prefix "r" + source_url "https://github.com/bearcove/picante" + include "docs/spec/**/*.md" + + impl { + name "main" + include "crates/**/*.rs" + exclude "target/**" + } +} diff --git a/.gitignore b/.gitignore index 767840b..c3ae76b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ /target +.handoffs .claude .cache .dodeca.db docs/public lcov.info +.tracey/ diff --git a/crates/picante-macros/src/db.rs b/crates/picante-macros/src/db.rs index b4a5e3d..557bffc 100644 --- a/crates/picante-macros/src/db.rs +++ b/crates/picante-macros/src/db.rs @@ -5,6 +5,8 @@ use proc_macro2::{Delimiter, Ident, TokenStream as TokenStream2, TokenTree}; use quote::{format_ident, quote}; use unsynn::{IParse, ToTokenIter, ToTokens}; +// r[macro.db.purpose] + #[derive(Default)] struct DbArgs { inputs: Vec, @@ -440,6 +442,7 @@ pub(crate) fn expand(attr: TokenStream, item: TokenStream) -> TokenStream { }); } + // r[snapshot.interned] // Interned: share the Arc (append-only, stable) for interned in &args.interned { let entity = &interned.name; @@ -521,6 +524,10 @@ pub(crate) fn expand(attr: TokenStream, item: TokenStream) -> TokenStream { }); } + // r[macro.db.snapshot] + // r[snapshot.frozen] + // r[snapshot.independent] + // r[snapshot.multiple] let snapshot_def = quote! { /// A point-in-time snapshot of the database. /// @@ -538,6 +545,8 @@ pub(crate) fn expand(attr: TokenStream, item: TokenStream) -> TokenStream { } impl #snapshot_name { + // r[snapshot.creation] + // r[snapshot.async] /// Create a snapshot from a database. /// /// This captures the current state of all inputs and cached query results. @@ -622,6 +631,7 @@ pub(crate) fn expand(attr: TokenStream, item: TokenStream) -> TokenStream { all_ingredient_fields.push(quote! { &*self.#field }); } + // r[macro.db.output] let expanded = quote! { #(#struct_attrs)* #vis struct #db_name { diff --git a/crates/picante-macros/src/input.rs b/crates/picante-macros/src/input.rs index 9f73e73..1961171 100644 --- a/crates/picante-macros/src/input.rs +++ b/crates/picante-macros/src/input.rs @@ -4,6 +4,7 @@ use proc_macro::TokenStream; use proc_macro2::{Ident, TokenStream as TokenStream2}; use quote::{format_ident, quote}; +// r[macro.input.purpose] pub(crate) fn expand(item: TokenStream) -> TokenStream { let item: TokenStream2 = item.into(); let parsed = match StructItem::parse(item) { @@ -26,6 +27,7 @@ pub(crate) fn expand(item: TokenStream) -> TokenStream { } } +// r[macro.input.keyed] /// Expand a keyed input (has exactly one #[key] field) fn expand_keyed( parsed: &StructItem, @@ -93,6 +95,7 @@ fn expand_keyed( quote! { let _ = __picante_assert_field_traits::<#ty>; } }); + // r[macro.input.kind-id] let expanded = quote! { /// Stable kind id for the key interner of `#name`. #vis const #keys_kind: picante::QueryKindId = picante::QueryKindId::from_str(concat!( @@ -245,6 +248,7 @@ fn split_key_field(parsed: &StructItem) -> KeyFieldResult<'_> { } } +// r[macro.input.singleton] /// Expand a singleton input (no #[key] field) fn expand_singleton( parsed: &StructItem, diff --git a/crates/picante-macros/src/interned.rs b/crates/picante-macros/src/interned.rs index 708eb7d..9c77f87 100644 --- a/crates/picante-macros/src/interned.rs +++ b/crates/picante-macros/src/interned.rs @@ -4,6 +4,7 @@ use proc_macro::TokenStream; use proc_macro2::{Ident, TokenStream as TokenStream2}; use quote::{format_ident, quote}; +// r[macro.interned.purpose] pub(crate) fn expand(item: TokenStream) -> TokenStream { let item: TokenStream2 = item.into(); let parsed = match StructItem::parse(item) { @@ -68,6 +69,7 @@ pub(crate) fn expand(item: TokenStream) -> TokenStream { quote! { let _ = __picante_assert_field_traits::<#ty>; } }); + // r[macro.interned.output] let expanded = quote! { /// Stable kind id for interned `#name` values. #vis const #kind_const: picante::QueryKindId = diff --git a/crates/picante-macros/src/tracked.rs b/crates/picante-macros/src/tracked.rs index 53cd120..bbf75d1 100644 --- a/crates/picante-macros/src/tracked.rs +++ b/crates/picante-macros/src/tracked.rs @@ -6,6 +6,7 @@ use proc_macro::TokenStream; use proc_macro2::{Ident, TokenStream as TokenStream2}; use quote::{format_ident, quote}; +// r[macro.tracked.purpose] pub(crate) fn expand(item: TokenStream) -> TokenStream { let item: TokenStream2 = item.into(); let parsed = match FnItem::parse(item) { @@ -53,9 +54,11 @@ pub(crate) fn expand(item: TokenStream) -> TokenStream { Err(e) => return compile_error(&e), }; + // r[macro.tracked.key-tuple] let (key_ty, key_expr, unpack_key) = build_key(&parsed.params[1..]); let call_impl = build_call_impl(&impl_name, parsed.is_async, &parsed.params[1..]); + // r[macro.tracked.return-wrap] let compute = if returns_picante_result { quote! { #call_impl } } else { @@ -95,6 +98,7 @@ pub(crate) fn expand(item: TokenStream) -> TokenStream { quote! { where #db_ident: #db_bounds } }; + // r[macro.tracked.output] let expanded = quote! { /// Stable kind id for this query. #vis const #kind_const: picante::QueryKindId = diff --git a/crates/picante/src/debug.rs b/crates/picante/src/debug.rs index 31b0a88..b76b73d 100644 --- a/crates/picante/src/debug.rs +++ b/crates/picante/src/debug.rs @@ -39,6 +39,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::Mutex; +// r[debug.graph] /// A snapshot of the dependency graph for visualization and analysis. #[derive(Debug, Clone)] pub struct DependencyGraph { @@ -208,6 +209,7 @@ impl DependencyGraph { } } +// r[debug.cache-stats] /// Statistics about cache usage and performance. #[derive(Debug, Clone)] pub struct CacheStats { @@ -332,6 +334,7 @@ pub enum TraceEvent { }, } +// r[debug.trace-collector] /// A collector that records runtime events for analysis. /// /// This subscribes to the runtime's event stream and records @@ -467,6 +470,7 @@ impl TraceCollector { } } +// r[debug.trace-analysis] /// Analysis of a collected trace. #[derive(Debug, Clone)] pub struct TraceAnalysis { diff --git a/crates/picante/src/error.rs b/crates/picante/src/error.rs index 8f809ce..b3a4b11 100644 --- a/crates/picante/src/error.rs +++ b/crates/picante/src/error.rs @@ -4,9 +4,12 @@ use crate::key::{DynKey, QueryKindId}; use std::fmt; use std::sync::Arc; +// r[error.result] /// Result type used by Picante APIs. pub type PicanteResult = std::result::Result>; +// r[error.type] +// r[error.variants] /// A Picante runtime / persistence error. #[derive(Debug)] pub enum PicanteError { diff --git a/crates/picante/src/frame.rs b/crates/picante/src/frame.rs index dceeb1b..006ceee 100644 --- a/crates/picante/src/frame.rs +++ b/crates/picante/src/frame.rs @@ -8,14 +8,18 @@ use std::future::Future; use std::sync::Arc; use tracing::trace; +// r[frame.task-local] +// r[frame.cycle-stack] tokio::task_local! { static ACTIVE_STACK: RefCell>; } +// r[frame.purpose] /// A cheap, clonable handle for the currently-running query frame. #[derive(Clone)] pub struct ActiveFrameHandle(Arc); +// r[frame.no-lock-await] struct ActiveFrameInner { dyn_key: DynKey, started_at: Revision, @@ -87,6 +91,8 @@ pub fn has_active_frame() -> bool { .unwrap_or(false) } +// r[frame.record-dep] +// r[dep.recording] /// Record a dependency on the current top-of-stack frame, if any. pub fn record_dep(dep: Dep) { let _ = ACTIVE_STACK.try_with(|stack| { @@ -96,6 +102,8 @@ pub fn record_dep(dep: Dep) { }); } +// r[frame.cycle-detect] +// r[frame.cycle-per-task] /// If `requested` already exists in the task-local stack, returns the full stack of `DynKey`s. pub fn find_cycle(requested: &DynKey) -> Option> { ACTIVE_STACK diff --git a/crates/picante/src/inflight.rs b/crates/picante/src/inflight.rs index 4d02945..a0b7b8f 100644 --- a/crates/picante/src/inflight.rs +++ b/crates/picante/src/inflight.rs @@ -18,6 +18,8 @@ use tracing::trace; /// Type-erased result value from a computation. pub(crate) type ArcAny = Arc; +// r[inflight.registry] +// r[inflight.purpose] /// Global registry for in-flight computations. /// /// This allows concurrent queries from different database snapshots to share @@ -29,6 +31,7 @@ static IN_FLIGHT_REGISTRY: std::sync::LazyLock>, > = std::sync::LazyLock::new(|| parking_lot::Mutex::new(VecDeque::new())); +// r[inflight.shared-cache-size] static SHARED_CACHE_MAX_ENTRIES: AtomicUsize = AtomicUsize::new(20_000); static SHARED_CACHE_MAX_ENTRIES_OVERRIDDEN: AtomicBool = AtomicBool::new(false); static SHARED_CACHE_INSERT_ID: AtomicU64 = AtomicU64::new(1); @@ -132,6 +138,8 @@ pub fn __test_shared_cache_set_max_entries(max_entries: usize) { SHARED_CACHE_MAX_ENTRIES_OVERRIDDEN.store(true, Ordering::Relaxed); } +// r[inflight.key] +// r[inflight.scope] /// Key identifying an in-flight computation. /// /// Two queries are considered the same if they have the same: @@ -221,6 +229,7 @@ impl InFlightEntry { pub(crate) enum TryLeadResult { /// We became the leader. The guard MUST be used to complete/fail/cancel. Leader(InFlightGuard), + // r[inflight.follower] /// Someone else is already computing. Wait on the entry. Follower(Arc), } @@ -235,6 +244,7 @@ pub(crate) struct InFlightGuard { } impl InFlightGuard { + // r[inflight.complete] /// Mark the computation as successfully completed. pub(crate) fn complete(mut self, value: ArcAny, deps: Arc<[Dep]>, changed_at: Revision) { self.entry.complete(value, deps, changed_at); @@ -244,6 +254,7 @@ impl InFlightGuard { IN_FLIGHT_REGISTRY.remove(&self.key); } + // r[inflight.fail] /// Mark the computation as failed. pub(crate) fn fail(mut self, error: Arc) { self.entry.fail(error); @@ -252,6 +263,7 @@ impl InFlightGuard { } } +// r[inflight.cancel] impl Drop for InFlightGuard { fn drop(&mut self) { if !self.completed { @@ -263,6 +275,7 @@ impl Drop for InFlightGuard { } } +// r[inflight.try-lead] /// Try to become the leader for a computation, or get the existing entry if /// someone else is already computing. pub(crate) fn try_lead(key: InFlightKey) -> TryLeadResult { diff --git a/crates/picante/src/ingredient/derived.rs b/crates/picante/src/ingredient/derived.rs index c541090..b988eb9 100644 --- a/crates/picante/src/ingredient/derived.rs +++ b/crates/picante/src/ingredient/derived.rs @@ -37,7 +37,9 @@ type ComputeFut<'a> = BoxFuture<'a, PicanteResult>; struct ErasedRecordData { dyn_key: DynKey, value: ArcAny, + // r[revision.verified-at] verified_at: Revision, + // r[revision.changed-at] changed_at: Revision, deps: Arc<[Dep]>, } @@ -83,6 +85,7 @@ struct ApplyWalResult { /// Function pointer for deep equality check without knowing V type EqErasedFn = fn(&dyn Any, &dyn Any) -> bool; +// r[type-erasure.mechanism] /// Trait for type-erased compute function (dyn dispatch) /// /// This trait allows the state machine to call compute() without being generic @@ -101,6 +104,8 @@ struct TypedCompute { _phantom: PhantomData<(K, V)>, } +// r[type-erasure.tradeoffs] +// r[derived.compute-fn] impl ErasedCompute for TypedCompute where DB: IngredientLookup + Send + Sync + 'static, @@ -108,6 +113,7 @@ where V: Send + Sync + 'static, { fn compute<'a>(&'a self, db: &'a DB, key: Key) -> ComputeFut<'a> { + // Tradeoffs: vtable dispatch, boxed future allocation, and key decode per compute. Box::pin(async move { let k: K = key.decode_facet()?; let v: V = (self.f)(db, k).await?; @@ -132,6 +138,8 @@ where // Non-generic core: state machine compiled ONCE // ============================================================================ +// r[type-erasure.purpose] +// r[type-erasure.benefit] /// Non-generic core containing the type-erased state machine. /// /// By keeping this struct non-generic and making its methods generic over parameters, @@ -368,6 +376,7 @@ impl DerivedCore { continue; } + // r[inflight.shared-cache-adopt] // 3) Check shared completed-result cache for cross-snapshot memoization. // Unlike the in-flight registry, this persists after the leader finishes. if let Some(record) = @@ -560,6 +569,7 @@ impl DerivedCore { "inflight: leader, computing" ); + // r[cell.no-lock-await] // Run compute under an active frame. let frame = ActiveFrameHandle::new(requested.clone(), rev); let _frame_guard = frame::push_frame(frame.clone()); @@ -582,6 +592,8 @@ impl DerivedCore { // 4) finalize match result { Ok(Ok(out)) => { + // r[revision.early-cutoff] + // r[cell.compute] let changed_at = match prev { Some((prev_value, prev_changed_at)) => { // Fast path: pointer equality (values are literally the same Arc) @@ -704,6 +716,8 @@ impl DerivedCore { } } + // r[cell.revalidate] + // r[cell.revalidate-missing] async fn try_revalidate( &self, db: &DB, @@ -1104,6 +1118,8 @@ where // Thin generic wrapper (one per DB/K/V, but minimal code) // ============================================================================ +// r[derived.type] +// r[derived.memoization] /// A memoized async derived query ingredient. /// /// This is a thin wrapper around `DerivedCore` that handles key encoding @@ -1173,6 +1189,8 @@ where self.core.kind_name } + // r[derived.get] + // r[cell.access] /// Get the value for `key` at the database's current revision. pub async fn get(&self, db: &DB, key: K) -> PicanteResult { // Encode key once (avoids re-encoding on every lookup) @@ -1309,6 +1327,7 @@ where Ok(true) } + // r[snapshot.derived] /// Create a deep snapshot of this ingredient's cells. /// /// Unlike `snapshot()` which shares `Arc` references, this method @@ -1369,9 +1388,11 @@ where /// monomorphized for every query type, dramatically reducing compile times. pub struct ErasedCell { state: Mutex, + // r[cell.waiter] notify: Notify, } +// r[cell.states] /// Type-erased state (not generic over V). /// /// Values are stored as `Arc` where the Any contains V. @@ -1381,9 +1402,13 @@ pub struct ErasedCell { /// - Single compilation of state machine logic enum ErasedState { Vacant, + // r[cell.leader-local] Running { started_at: Revision, }, + // r[cell.stale] + // r[revision.verified-at] + // r[revision.changed-at] Ready { /// The cached value, stored as Arc where the Any is V. /// Use Arc::downcast::() to recover the Arc. @@ -1392,6 +1417,8 @@ enum ErasedState { changed_at: Revision, deps: Arc<[Dep]>, }, + // r[cell.poison] + // r[cell.poison-scoped] Poisoned { error: Arc, verified_at: Revision, diff --git a/crates/picante/src/ingredient/input.rs b/crates/picante/src/ingredient/input.rs index 75bda63..6af01d7 100644 --- a/crates/picante/src/ingredient/input.rs +++ b/crates/picante/src/ingredient/input.rs @@ -161,6 +161,7 @@ impl InputCore { // Helper functions to create persistence callbacks (monomorphized per K,V) // ============================================================================ +// r[input.constraints] fn make_encode_input_record() -> EncodeInputRecordFn where K: Clone + Eq + Hash + Facet<'static> + Send + Sync + 'static, @@ -324,6 +325,7 @@ pub struct InputEntry { pub changed_at: Revision, } +// r[input.type] /// A key-value input ingredient. /// /// Reads record dependencies into the current query frame (if one exists). @@ -375,6 +377,8 @@ where self.core.kind_name } + // r[input.set] + // r[input.revision-on-change] /// Set an input value. /// /// Bumps the runtime revision only if the value actually changed. @@ -424,6 +428,7 @@ where rev } + // r[input.remove] /// Remove an input value. /// /// Bumps the runtime revision only if the value existed. @@ -476,6 +481,7 @@ where rev } + // r[input.get] /// Read an input value. /// /// If there's an active query frame, records a dependency edge. @@ -515,6 +521,7 @@ where entries.get(&dyn_key).map(|e| e.changed_at) } + // r[snapshot.input] /// Create a snapshot of this ingredient's data. /// /// This is an O(1) operation due to structural sharing in `im::HashMap`. diff --git a/crates/picante/src/ingredient/interned.rs b/crates/picante/src/ingredient/interned.rs index 5cb9738..872b1b6 100644 --- a/crates/picante/src/ingredient/interned.rs +++ b/crates/picante/src/ingredient/interned.rs @@ -12,11 +12,14 @@ use std::sync::Arc; use std::sync::atomic::{AtomicU32, Ordering}; use tracing::{debug, trace}; +// r[interned.id-type] /// An identifier returned from [`InternedIngredient::intern`]. #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Facet)] #[repr(transparent)] pub struct InternId(pub u32); +// r[interned.type] +// r[interned.stability] /// An ingredient that interns values and returns stable ids. /// /// Interned values are immutable: interning does **not** bump the database revision. @@ -28,6 +31,7 @@ pub struct InternedIngredient { by_id: DashMap>, } +// r[interned.constraints] impl InternedIngredient where K: Facet<'static> + Send + Sync + 'static, @@ -53,6 +57,7 @@ where self.kind_name } + // r[interned.intern] /// Intern `value` and return its stable id. pub fn intern(&self, value: K) -> PicanteResult { let _span = tracing::debug_span!("intern", kind = self.kind.0).entered(); @@ -76,6 +81,7 @@ where } } + // r[interned.get] /// Look up an interned value by id. /// /// If there's an active query frame, records a dependency edge. diff --git a/crates/picante/src/key.rs b/crates/picante/src/key.rs index b998ce6..4689e88 100644 --- a/crates/picante/src/key.rs +++ b/crates/picante/src/key.rs @@ -6,6 +6,7 @@ use std::fmt; use std::hash::{Hash, Hasher}; use std::sync::Arc; +// r[kind.type] /// Stable identifier for a query/input kind. #[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)] pub struct QueryKindId(pub u32); @@ -16,6 +17,10 @@ impl QueryKindId { self.0 } + // r[kind.hash] + // r[kind.stability] + // r[kind.collision] + // r[kind.uniqueness] /// Create a stable id from a string. /// /// This is intended for macro-generated kind ids, which must remain stable @@ -24,21 +29,23 @@ impl QueryKindId { /// The hash algorithm is a 32-bit FNV-1a over UTF-8 bytes. pub const fn from_str(s: &str) -> Self { let bytes = s.as_bytes(); - let mut hash: u32 = 0x811c9dc5; + let mut hash: u32 = 0x811c9dc5; // FNV_OFFSET let mut i = 0usize; while i < bytes.len() { hash ^= bytes[i] as u32; - hash = hash.wrapping_mul(0x0100_0193); + hash = hash.wrapping_mul(0x0100_0193); // FNV_PRIME i += 1; } QueryKindId(hash) } } +// r[key.encoding] /// Postcard-encoded bytes for a key, plus a deterministic hash for tracing/debugging. #[derive(Clone)] pub struct Key { bytes: Arc<[u8]>, + // r[key.hash] hash: u64, } @@ -94,8 +101,10 @@ impl Key { } } +// r[key.equality] impl PartialEq for Key { fn eq(&self, other: &Self) -> bool { + // exact byte equality, not hash self.bytes == other.bytes } } @@ -117,6 +126,7 @@ impl fmt::Debug for Key { } } +// r[key.dyn-key] /// Erased key for diagnostics/cycle detection. #[derive(Clone, Eq, PartialEq, Hash, Debug)] pub struct DynKey { @@ -126,6 +136,7 @@ pub struct DynKey { pub key: Key, } +// r[key.dep] /// A recorded dependency edge. #[derive(Clone, Eq, PartialEq, Hash, Debug)] pub struct Dep { diff --git a/crates/picante/src/lib.rs b/crates/picante/src/lib.rs index 65adf0d..d3f0868 100644 --- a/crates/picante/src/lib.rs +++ b/crates/picante/src/lib.rs @@ -1,3 +1,6 @@ +// r[trace.crate] +// r[trace.no-subscriber] +// r[trace.levels] #![warn(missing_docs)] #![doc = include_str!("../../../README.md")] diff --git a/crates/picante/src/persist.rs b/crates/picante/src/persist.rs index 5a46caf..b73737c 100644 --- a/crates/picante/src/persist.rs +++ b/crates/picante/src/persist.rs @@ -12,6 +12,8 @@ use std::path::Path; use std::sync::Arc; use tracing::{debug, info, warn}; +// r[persist.format] +// r[persist.load-version] const FORMAT_VERSION: u32 = 1; /// Controls how Picante behaves when a cache file can't be decoded/validated. @@ -56,7 +58,12 @@ pub struct CacheSaveOptions { pub max_record_bytes: Option, } +// r[persist.structure] +// r[persist.not-stored] /// Top-level cache file payload (encoded with `facet-postcard`). +/// +/// Note: Custom database fields and the dependency graph are NOT stored here. +/// The dependency graph is reconstructed during load from ingredient records. #[derive(Debug, Clone, Facet)] pub struct CacheFile { /// Cache format version. @@ -67,6 +74,7 @@ pub struct CacheFile { pub sections: Vec
, } +// r[persist.section] /// A per-ingredient cache section. #[derive(Debug, Clone, Facet)] pub struct Section { @@ -149,6 +157,7 @@ pub trait PersistableIngredient: Send + Sync { } } +// r[persist.save-fn] /// Save `runtime` and `ingredients` to `path`. pub async fn save_cache( path: impl AsRef, @@ -158,6 +167,9 @@ pub async fn save_cache( save_cache_with_options(path, runtime, ingredients, &CacheSaveOptions::default()).await } +// r[persist.save-options] +// r[persist.save-atomic] +// r[persist.save-unique-kinds] /// Save `runtime` and `ingredients` to `path` with cache size limits. pub async fn save_cache_with_options( path: impl AsRef, @@ -244,6 +256,8 @@ pub async fn save_cache_with_options( Ok(()) } +// r[persist.load-fn] +// r[persist.load-return] /// Load `runtime` and `ingredients` from `path`. /// /// Returns `Ok(false)` if the cache file does not exist. @@ -255,6 +269,7 @@ pub async fn load_cache( load_cache_with_options(path, runtime, ingredients, &CacheLoadOptions::default()).await } +// r[persist.load-options] /// Load `runtime` and `ingredients` from `path` with a corruption policy. /// /// Returns `Ok(false)` if the cache file does not exist, is ignored, or is deleted. @@ -282,6 +297,10 @@ pub async fn load_cache_with_options( } } +// r[persist.load-order] +// r[persist.load-kind-match] +// r[persist.load-name-match] +// r[persist.load-type-match] async fn load_cache_inner( path: &Path, runtime: &Runtime, @@ -312,6 +331,7 @@ async fn load_cache_inner( let cache: CacheFile = decode_cache_file(&bytes)?; + // r[persist.load-version] if cache.format_version != FORMAT_VERSION { return Err(Arc::new(PicanteError::Cache { message: format!( diff --git a/crates/picante/src/revision.rs b/crates/picante/src/revision.rs index c67959b..4681e6f 100644 --- a/crates/picante/src/revision.rs +++ b/crates/picante/src/revision.rs @@ -2,6 +2,8 @@ use facet::Facet; +// r[revision.type] +// r[revision.monotonic] /// A monotonically increasing revision counter. #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Facet)] pub struct Revision(pub u64); diff --git a/crates/picante/src/runtime.rs b/crates/picante/src/runtime.rs index 5c05b85..36ec80c 100644 --- a/crates/picante/src/runtime.rs +++ b/crates/picante/src/runtime.rs @@ -11,6 +11,7 @@ use tokio::sync::{broadcast, watch}; /// Global counter for assigning unique runtime IDs. static RUNTIME_ID_COUNTER: AtomicU64 = AtomicU64::new(1); +// r[snapshot.runtime-id] /// Unique identifier for a database runtime. /// /// This ID is used to distinguish between different database instances for @@ -27,19 +28,25 @@ impl RuntimeId { } } +// r[runtime.components] /// Shared runtime state for a Picante database: primarily the current revision. #[derive(Debug)] pub struct Runtime { /// Unique identifier for this runtime family (shared with snapshots). id: RuntimeId, current_revision: AtomicU64, + // r[event.channel] revision_tx: watch::Sender, + // r[event.broadcast-capacity] events_tx: broadcast::Sender, + // r[dep.forward] deps_by_query: DashMap>, + // r[dep.reverse] reverse_deps: DashMap>, } impl Runtime { + // r[runtime.new] /// Create a new runtime starting at revision 0. pub fn new() -> Self { Self::default() @@ -86,6 +93,7 @@ impl Runtime { self.events_tx.subscribe() } + // r[revision.bump] /// Bump the current revision and return the new value. pub fn bump_revision(&self) -> Revision { let next = self.current_revision.fetch_add(1, Ordering::AcqRel) + 1; @@ -97,6 +105,7 @@ impl Runtime { rev } + // r[revision.set] /// Set the current revision (intended for cache loading). pub fn set_current_revision(&self, revision: Revision) { self.current_revision.store(revision.0, Ordering::Release); @@ -104,6 +113,7 @@ impl Runtime { let _ = self.events_tx.send(RuntimeEvent::RevisionSet { revision }); } + // r[runtime.notify-input-set] /// Emit an input change event (for live reload / diagnostics). pub fn notify_input_set(&self, revision: Revision, kind: QueryKindId, key: Key) { let source = DynKey { @@ -134,6 +144,7 @@ impl Runtime { self.propagate_invalidation(revision, &source); } + // r[runtime.update-deps] /// Update the dependency edges for `query`. pub fn update_query_deps(&self, query: DynKey, deps: Arc<[Dep]>) { let old = self.deps_by_query.insert(query.clone(), deps.clone()); @@ -171,6 +182,7 @@ impl Runtime { } } + // r[event.query-changed-cutoff] /// Emit a derived query change event (for live reload / diagnostics). pub fn notify_query_changed(&self, revision: Revision, query: DynKey) { let _ = self.events_tx.send(RuntimeEvent::QueryChanged { @@ -181,6 +193,7 @@ impl Runtime { }); } + // r[runtime.clear-deps] /// Clear the in-memory dependency graph (used during cache loads). pub fn clear_dependency_graph(&self) { self.deps_by_query.clear(); @@ -212,6 +225,8 @@ impl Runtime { .collect() } + // r[runtime.propagate-invalidation] + // r[dep.invalidation] fn propagate_invalidation(&self, revision: Revision, source: &DynKey) { let mut queue = VecDeque::new(); let mut seen: HashSet = HashSet::new(); @@ -246,9 +261,11 @@ impl Runtime { } } +// r[revision.initial] impl Default for Runtime { fn default() -> Self { let (revision_tx, _) = watch::channel(Revision(0)); + // r[event.broadcast-capacity] let (events_tx, _) = broadcast::channel(1024); Self { id: RuntimeId::new_unique(), @@ -261,6 +278,8 @@ impl Default for Runtime { } } +// r[event.types] +// r[event.key-fields] /// Notifications emitted by a [`Runtime`]. #[derive(Debug, Clone)] pub enum RuntimeEvent { diff --git a/docs/spec/picante.md b/docs/spec/picante.md new file mode 100644 index 0000000..c22939a --- /dev/null +++ b/docs/spec/picante.md @@ -0,0 +1,863 @@ +# Picante Semantics Specification + +Picante is an incremental query runtime for Rust: you declare **inputs** and **derived queries**, and the runtime memoizes query results, tracks dependencies automatically, and recomputes only when the dependencies’ values change. + +This document specifies **observable semantics**: what a user of the API can rely on (values, errors, and visibility across revisions/snapshots). It intentionally avoids prescribing implementation techniques, data structures, async primitives, logging/tracing backends, or performance tradeoffs. + +--- + +## Model and Terms + +### Walkthrough (non-normative) + +This short example illustrates how inputs, derived queries, revisions, and snapshots relate. + +Definitions: + +```rust +use picante::PicanteResult; +use std::path::PathBuf; +use std::sync::Arc; + +#[picante::input] +pub struct FileDigest { + #[key] + pub path: PathBuf, + pub hash: [u8; 32], +} + +#[picante::tracked] +pub async fn read_file_bytes( + _db: &DB, + path: PathBuf, + hash: [u8; 32], +) -> PicanteResult>> { + // Read the file at `path` and return its bytes. + // `hash` is part of the query key: changing it forces a different cached entry. + # let _ = (path, hash); + Ok(Arc::new(vec![])) +} +``` + +Timeline (conceptual): + +1. Start with a new database view at some initial revision. +2. Set `FileDigest { path: "a.txt", hash: H1 }`. This advances the view to a later revision (because observable input state changed). +3. Call `read_file_bytes(db, "a.txt", H1)`. The runtime computes it once and caches the result. +4. If you call `read_file_bytes(db, "a.txt", H1)` again at a later time *without changing inputs*, the cached value may be returned. +5. When the file changes, update `FileDigest { path: "a.txt", hash: H2 }`. This advances the revision again. +6. A new call `read_file_bytes(db, "a.txt", H2)` uses a different key and is computed/cached independently of the `H1` entry. +7. If you take a snapshot after step 5, the snapshot view “freezes” `FileDigest("a.txt") == H2` and continues to observe that value even if the primary view is later mutated. + +### Concurrency model (high level) + +This specification uses the standard notion of **linearizability** for concurrent operations. +Informally: every operation behaves as if it took effect at a single instant between when it started and when it returned. + +> r[concurrency.linearizable] +> All observable operations on a view (input reads, input mutations, snapshot creation, and derived-query accesses) MUST be linearizable: +> +> - For each operation call, there MUST exist a single linearization point between invocation and completion. +> - There MUST exist a total order of all completed operations consistent with real-time ordering (if A completes before B starts, then A appears before B in that order). +> - Each operation’s result MUST be the same as if operations executed sequentially in that total order. +> +> This requirement forbids “torn” observations: callers MUST NOT observe partial application of an operation. + +> r[liveness.scope] +> This specification primarily defines safety properties for completed operations (what results are permitted). +> It does not guarantee liveness: an operation is permitted to block indefinitely (for example due to user-level deadlocks, executor starvation, or infinite recursion), except where explicitly stated otherwise. + +### Database and views + +A **database** is the logical unit of Picante state you care about: a set of inputs and derived-query semantics, plus (optionally) any snapshots derived from it. + +A **view** is a particular handle you run queries against and apply mutations to. There are two common view kinds: + +- the **primary view** (the `Database` value you constructed with `Database::new()`), and +- **snapshot views** (values like `DatabaseSnapshot` created from a view). + +This is not “per task” or “per thread”; it is “per database/view object”. + +#### Multiple databases and views in one program (non-normative) + +You can have multiple independent Picante databases in a single Rust program by constructing multiple primary views: + +```rust +#[picante::db(inputs(Item), interned(Label), tracked(item_length))] +pub struct Database {} + +let db_a = Database::new(); // database A, primary view +let db_b = Database::new(); // database B, primary view (independent from A) +``` + +Each `Database::new()` creates a new database with independent observable state. +Operationally, this means mutations in `db_a` are not observable through `db_b`, and vice versa. +By contrast, cloning a shared reference to the same view (e.g. `Arc`) does not create a new database or a new view; it is still the same database handle. + +You create additional views *of the same database* by taking snapshots: + +```rust +let snap_a1 = DatabaseSnapshot::from_database(&db_a).await; // database A, snapshot view 1 +let snap_a2 = DatabaseSnapshot::from_database(&db_a).await; // database A, snapshot view 2 +``` + +Snapshots are separate views (they can cache derived-query results independently and evolve independently), but they remain views of the same database. + +### Revision + +r[revision.type] +A **revision** is an opaque token that identifies a database state within a view. + +r[revision.order] +Within a view, revisions MUST form a total order consistent with the “happens-after” ordering of successful input mutations that change observable state in that view. + +r[revision.advance] +Any successful input mutation that changes observable input state MUST advance the view to a fresh revision that is greater than the prior revision. + +> r[revision.choose] +> Each read or derived-query access on a view MUST be associated with a specific revision `R` of that view. +> The revision `R` is the view’s current revision at the operation’s linearization point (see `r[concurrency.linearizable]`). + +### Ingredients and records + +An **ingredient** is a category of stored/computed data. Picante defines three ingredient kinds: + +- **Input ingredient**: mutable key-value storage set by user code. +- **Derived ingredient**: an async query whose value is computed from inputs and other derived queries. +- **Interned ingredient**: a value-to-ID mapping that is append-only (interned values never change once created). + +Each ingredient stores **records** addressed by a **key**. + +### Keys and kinds + +Every record is addressed by: + +- a **kind** identifying which ingredient it belongs to, and +- a **key** identifying the record within that ingredient. + +Rust-centric intuition: a “kind” corresponds to a specific ingredient definition in your Rust program (an `#[picante::input]` type, an `#[picante::interned]` type, or an `#[picante::tracked]` function). Each kind defines its own disjoint keyspace. + +#### Input + +`#[picante::input]` defines a kind for input records. + +Keyed inputs use the `#[key]` field value as the key: + +```rust +#[picante::input] +pub struct Item { + #[key] + pub id: u32, + pub value: String, +} +``` + +Here, the key is `id: u32` (e.g. `ItemKey(1)` addresses the `id == 1` record). + +Singleton inputs (no `#[key]` field) are conceptually keyed by a unit-like key: there is exactly one record. + +#### Tracked (derived query) + +`#[picante::tracked]` defines a kind for derived-query results. +The key is the tuple of the function’s parameters after `db` (one parameter means a 1-tuple): + +```rust +use picante::PicanteResult; + +#[picante::tracked] +pub async fn item_length(db: &DB, item: Item) -> PicanteResult { + Ok(item.value(db)?.len() as u64) +} +``` + +Here, the key is `(item,)`, where `item` is the `Item` handle (its stable identity), not the mutable `ItemData` contents. + +For multiple parameters after `db`, the key is the tuple of all arguments in order: + +```rust +#[picante::tracked] +pub async fn item_has_label( + db: &DB, + item: Item, + label: Label, +) -> PicanteResult { + // ... + # let _ = (db, item, label); + # Ok(false) +} +``` + +Here, the key is `(item, label)`. +This “argument tuple” rule means you can control key shape by choosing your function signature. +If you want a single structured key rather than a multi-arg tuple, wrap it in a newtype: + +```rust +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct ItemLabelKey { + pub item: Item, + pub label: Label, +} + +#[picante::tracked] +pub async fn item_has_label2( + db: &DB, + key: ItemLabelKey, +) -> PicanteResult { + # let _ = (db, key); + Ok(false) +} +``` + +In this second form, the key is `(ItemLabelKey,)`. + +#### Interned + +`#[picante::interned]` defines a kind for an append-only intern table. +Interned records are addressed by the intern ID handle: + +```rust +#[picante::interned] +pub struct Label { + pub text: String, +} +``` + +Creating an intern (`Label::new(&db, "tag".into())`) conceptually looks up or inserts by value and returns a `Label(pub InternId)`. +Reading an intern (e.g. `label.text(&db)`) uses that ID as the key. + +An intern handle is a stable identity token: you can store it, pass it between queries, and use it as part of derived-query keys (as in the tracked example above). + +In this specification, record identity is always the pair `(kind, key)`. + +r[kind.identity] +A kind MUST uniquely identify a specific ingredient definition within a database type. +Two distinct ingredient definitions MUST NOT share the same kind. + +r[kind.mapping] +The mapping from Rust constructs (types/functions) to kinds is implementation-defined, but it MUST be deterministic within a single view and MUST preserve `r[kind.identity]`. + +--- + +# Core Semantics + +## Inputs + +### API direction (Rust, non-normative) + +This section is intentionally Rust-centric and describes a plausible *user-facing* API shape. +It is non-normative: implementations may expose different surface APIs, but the observable semantics are defined by the requirements below. + +#### Typed keys + +For keyed inputs, user code benefits from a distinct key type that: + +- is cheap to copy/clone and hash, +- is unambiguous at call sites (you can’t accidentally pass a `u32` key for the wrong record type), +- carries a link to the record type it addresses. + +One ergonomic pattern is to generate a `{Name}Key` type for each `#[picante::input]` kind: + +```rust +#[picante::input] +pub struct Item { + #[key] + pub id: u32, + pub value: String, +} + +/// Generated (illustrative). +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct ItemKey(pub u32); +``` + +Singleton inputs can use a unit-like key (generated or implicit): + +```rust +#[picante::input] +pub struct Config { + pub debug: bool, +} + +/// Generated (illustrative). +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct ConfigKey; +``` + +In addition, inputs often benefit from a “data-only” type (no key fields) to represent the non-key portion of an input record: + +```rust +/// Generated (illustrative). +pub struct ItemData { + pub value: String, +} + +/// Generated (illustrative). +pub struct ConfigData { + pub debug: bool, +} +``` + +This enables APIs like `db.get(ItemKey(1)) -> Option>` without repeating field names or allocating temporary structs. + +#### Core operations + +At the database level, one goal is to make the mutation/read operations feel like “normal Rust data access”: + +- `db.set(record)` for inserts/updates +- `db.get(key)` to read +- `db.remove(key)` to delete + +An illustrative shape (not required) looks like: + +```rust +pub trait InputKey: Copy + Eq + std::hash::Hash + Send + Sync + 'static { + /// The input record type addressed by this key. + type Record: InputRecord; +} + +pub trait InputRecord: Send + Sync + 'static { + type Key: InputKey; + type Data: Send + Sync + 'static; + + fn key(&self) -> Self::Key; + fn into_data(self) -> Self::Data; +} + +pub trait DbInputs { + fn get(&self, key: K) -> PicanteResult::Data>>>; + fn set(&self, record: R) -> PicanteResult; + fn remove(&self, key: K) -> PicanteResult; +} +``` + +This keeps `set` “obvious” (the record value carries its own key) while ensuring `get/remove` are type-safe (you must provide the correct key type). +Implementations may also offer convenience methods on the generated types (e.g. `Item::set(&db, ...)`, `ItemKey::get(&db)`) layered on top of the same semantics. + +For singleton inputs, `set` can remain “obvious” while `get/remove` stay typed: + +```rust +db.set(Config { debug: true })?; +let config = db.get(ConfigKey)?; +``` + +> r[input.get] +> Reading an input record by key MUST: +> +> - Return `Ok(Some(value))` if the record exists in the database state associated with `db`. +> - Return `Ok(None)` if the record does not exist in that database state. +> +> If called during derived query evaluation, it MUST record a dependency on that input record (see `r[dep.recording]`). + +> r[input.slot] +> For purposes of dependency tracking and invalidation, each input kind defines an **input slot** for every possible key. +> The observable value of an input slot is either `None` (absent) or `Some(value)` (present). +> +> A derived query that reads an input via `get(db, key)` MUST be treated as depending on that slot’s value (including absence). +> Therefore, changing a slot from `None` to `Some(...)`, from `Some(...)` to `None`, or from `Some(v1)` to `Some(v2)` MUST be observable via invalidation and revalidation. + +> r[input.concurrent] +> Input reads and input mutations on the same view MUST compose according to `r[concurrency.linearizable]`: +> +> - A concurrent `get` racing with a `set`/`remove` MUST behave as if either the `get` happened before the mutation (returning the old value) or after the mutation (returning the new value). +> - A `get` MUST NOT observe partial effects of a mutation. + +### Equality and change detection + +Picante’s observable semantics depend on a notion of when a value “changed”: + +- input `set` is a no-op iff the new value is equal to the old value, and +- derived recomputation applies early-cutoff iff the new value is equal to the old value. + +To be implementable and predictable, this specification requires implementations to use a well-defined equality relation. + +> r[equality.relation] +> For each input kind, derived kind, and interned kind, the implementation MUST define an equality relation `≈` over that kind’s relevant values such that `≈` is an equivalence relation (reflexive, symmetric, transitive). +> +> The relation MUST be deterministic: for any two values `a` and `b`, whether `a ≈ b` holds MUST NOT depend on timing, concurrency, global mutable state, or external state. +> +> The specific relation is otherwise implementation-defined (e.g., deep structural equality), but it MUST be consistent for the lifetime of the process. + +> r[input.set] +> Setting an input record MUST behave as follows: +> +> 1. If the record did not previously exist, it is created with the provided value. +> 2. If the record exists and the new value is equal to the current value (per `r[equality.relation]`), the operation MUST be a no-op. +> 3. If the record exists and the value differs from the current value, the value MUST be replaced. +> 4. The view revision MUST advance to a fresh later revision iff the operation is not a no-op. + +> r[input.remove] +> Removing an input record MUST behave as follows: +> +> 1. If the record does not exist, the operation MUST be a no-op. +> 2. If the record exists, it MUST be removed and the view revision MUST advance to a fresh later revision. + +> r[input.missing-access] +> If the public API provides a “required” read of an input record (i.e., an operation that does not return `Option`/`None` for absence), then attempting to read a missing/removed input slot MUST return an explicit error indicating a missing input value. +> +> This error MUST be stable and deterministic, and MUST NOT be silently mapped to a default value. + +### Batch mutations + +Many real workloads update multiple inputs together (e.g., a filesystem scan updating digests for many paths). + +An ergonomic direction is to provide a batch builder that accepts typed operations and commits them together: + +```rust +pub enum Mutation { + SetItem(Item), + RemoveItem(ItemKey), + // ... more generated variants, or a type-erased alternative ... +} + +pub trait DbBatch { + type Batch; + + fn batch(&self) -> Self::Batch; +} + +pub trait BatchOps { + fn set_item(&mut self, item: Item); + fn remove_item(&mut self, key: ItemKey); + fn commit(self) -> PicanteResult; +} +``` + +Picante MUST provide some batch input-mutation operation; the exact surface shape (builder vs. iterator vs. transactional closure) is up to the implementation. +The semantics are captured by `r[input.batch]`. + +> r[input.batch] +> Picante MUST provide a batch input-mutation operation that applies multiple `set`/`remove` mutations as one operation. +> The operation MUST be atomic with respect to observable database state: +> +> - There MUST exist a single revision boundary such that observers see either all batch mutations applied or none. +> - No observer MAY observe a state in which only a strict subset of the batch mutations have been applied. +> - The batch MUST have the same final effect as applying its component mutations sequentially in the order provided, and then committing the resulting state atomically at the batch’s revision boundary. +> - The view MUST advance to a fresh later revision iff at least one mutation in the batch changes observable input state; otherwise the batch is a no-op. + +> r[input.batch-conflicts] +> If a batch contains multiple mutations to the same input slot `(kind, key)`, the sequential “apply in order” rule in `r[input.batch]` determines the outcome: +> +> - Later mutations in the batch override earlier ones. +> - The final state of that slot after the batch MUST be the result of applying all of its mutations in order. + +## Derived queries + +### Revision binding (per access) + +Derived queries are always evaluated “at” a revision of a view (even if the evaluation is asynchronous and takes time). + +> r[derived.revision-binding] +> A derived-query access MUST be evaluated at its associated revision `R` (see `r[revision.choose]`): +> +> - All input reads and derived-query reads performed as part of that access MUST behave as reads at revision `R`. +> - If the view advances to a later revision while evaluation is in progress, that MUST NOT change the results of the in-progress access; it remains an evaluation at revision `R`. +> - A later access to the same derived query at a later associated revision `R' > R` is a distinct access and MAY yield a different result. + +> r[derived.concurrent] +> Derived-query accesses and input mutations on the same view MUST compose according to `r[concurrency.linearizable]` and the revision-binding rules: +> +> - If a derived-query access linearizes at revision `R`, it MUST evaluate as-of `R` even if the view advances to `R+1` while the access is in progress. +> - Therefore, an input mutation that linearizes after the derived-query access MUST NOT affect the in-progress access. + +### Determinism contract + +Picante’s caching and snapshot semantics are defined in terms of dependencies that flow through the database. +In practice, derived queries should behave like deterministic functions of the database records they read; otherwise, cached results can be surprising or stale with respect to the outside world. + +Note: Picante only tracks dependencies that flow through the database (inputs/derived queries/interned IDs). External state (filesystem contents, network responses, environment variables, clocks, etc.) is not automatically tracked or snapshotted. If a derived query reads external state without routing it through inputs, caching and snapshots can return values that do not reflect changes in that external state until some input change causes recomputation. + +### Modeling external state (non-normative) + +A common way to use Picante in tools that must read from disk is to make “what files exist and what version of each file exists” an explicit part of database state, and then key disk-reading queries by that version. + +For example, treat the filesystem’s *current digest* as an input, updated by a watcher or scanner: + +```rust +#[picante::input] +pub struct FileDigest { + #[key] + pub path: std::path::PathBuf, + pub hash: [u8; 32], +} +``` + +Then make disk reads a derived query keyed by `(path, hash)` so the cached result is specific to a particular content hash: + +```rust +#[picante::tracked] +pub async fn read_file( + _db: &DB, + path: std::path::PathBuf, + hash: [u8; 32], +) -> picante::PicanteResult>> { + // Read from disk here; `hash` is part of the key so changing it forces a new cached entry. + // If you care about correctness in the presence of races (e.g. a stale watcher digest or + // TOCTOU), validate that the bytes you read actually match `hash` and return an error if not. + let bytes: Vec = /* read bytes for `path` */; + let _expected: [u8; 32] = hash; + Ok(std::sync::Arc::new(bytes)) +} +``` + +Higher-level queries can then depend on `FileDigest(path)` to obtain the current `(path, hash)` pair and call `read_file(path, hash)`. +When the file changes, updating `FileDigest` causes downstream recomputation; if a file reverts to a previous hash, the `(path, hash)`-keyed cache allows reuse. + +### Dependency tracking + +> r[dep.recording] +> During evaluation of a derived query, each read of an input record or derived query result MUST be recorded as a dependency of the evaluating query for the purpose of future revalidation. +> +> Dependencies MUST be recorded with enough precision to revalidate: at minimum, the dependency’s `(kind, key)` identity. +> +> Implementations MAY omit recording dependencies on records whose values are immutable for the lifetime of the process (for example, interned records addressed by an ID), since such dependencies can never affect revalidation. + +### Per-ingredient `changed_at` semantics + +The revalidation and invalidation rules depend on each dependency exposing a notion of “the most recent revision at which this dependency changed” (`changed_at`). + +> r[changed-at.meaning] +> For any dependency record `(kind, key)` that participates in dependency tracking, the implementation MUST define a `changed_at` revision value with the following meaning: +> +> - If the record’s observable value has not changed between two revisions, its `changed_at` MUST be the same at both revisions. +> - If the record’s observable value changes at some revision `R`, then for all later revisions `R' >= R`, the record’s `changed_at` MUST be `>= R`. +> +> `changed_at` MUST always be `<=` the view revision at which it is observed. +> +> Implementations MAY represent `changed_at` implicitly (e.g., via monotonic counters) as long as it satisfies the ordering properties above. + +> r[changed-at.input] +> For an input slot `(kind, key)`, `changed_at` MUST advance to the view’s new revision exactly when a `set`/`remove` operation changes the slot’s observable value (per `r[equality.relation]` for `set`, and existence/absence for `remove`). +> No-op mutations MUST NOT change `changed_at`. + +> r[changed-at.derived] +> For a derived record, `changed_at` MUST be updated according to early-cutoff (`r[revision.early-cutoff]`): +> +> - If a recomputation at revision `R` produces a value equal to the previous cached value (per `r[equality.relation]`), `changed_at` MUST NOT advance. +> - If a recomputation at revision `R` produces a value not equal to the previous cached value, `changed_at` MUST become `R`. + +> r[changed-at.interned] +> For interned records addressed by an intern ID, the record’s observable value is immutable once created. +> Therefore, for purposes of dependency tracking, implementations MAY treat interned records as having a constant `changed_at` that never advances. +> +> Creation of new intern IDs MUST NOT change the observable value of any previously created interned record, and MUST NOT invalidate derived queries that depend only on existing intern IDs. + +> r[changed-at.poison] +> A derived record’s failure state (poison) is revision-scoped (see `r[cell.poison]`). +> Therefore, if `(kind, key)` failed at revision `R` and a later access occurs at revision `R' > R`, the earlier failure MUST NOT be treated as a valid cached result for `R'`. +> +> Implementations MUST ensure that dependents are not “stuck” on an earlier failure: +> when a dependent revalidates against a dependency that is only known to have failed at an earlier revision, revalidation MUST fail (causing recomputation) unless the dependency has been successfully recomputed/validated at the dependent’s revision. + +### Durability (change-frequency hint) + +Some systems (notably Salsa) annotate inputs with a **durability**: a coarse estimate of how often a value is expected to change. +Durability is an optimization hint for revalidation; it is **not persistence** and it MUST NOT affect observable results. + +> r[durability.levels] +> Picante MUST define three durability levels `LOW`, `MEDIUM`, and `HIGH` with a total order `LOW < MEDIUM < HIGH`. +> Higher durability means “expected to change less frequently”. + +> r[durability.inputs] +> Each input kind MUST have an associated durability level. +> Every input slot `(kind, key)` of that kind inherits the kind’s durability level. +> +> The durability level for an input kind MUST be specified as part of the ingredient definition (for example via a macro attribute parameter), and defaults to `LOW` if unspecified. + +> r[durability.interned] +> Interned records MUST be treated as `HIGH` durability, since they are immutable once created. +> Creating new intern IDs MUST NOT reduce the durability of existing interned records. + +> r[durability.nonobservable] +> Durability MUST be non-observable: changing durability annotations MUST NOT change which values/errors are returned by inputs, derived queries, or snapshots. +> Durability MAY affect performance by enabling the implementation to skip some revalidation work, but only in ways that are conservative with respect to correctness. + +> r[durability.revalidation-opt] +> Implementations MAY use durability to optimize revalidation as follows: +> +> - Track, for each durability level `D`, a per-view revision watermark `last_changed_at_at_or_above[D]` equal to the most recent revision at which any input slot whose input kind has durability `>= D` changed. +> - For each cached derived value, track an **effective durability** `eff_dur` equal to the minimum durability of all input kinds it (transitively) depends on. +> - When revalidating a derived value whose cached `verified_at >= last_changed_at_at_or_above[eff_dur]`, the implementation MAY treat revalidation as having succeeded without checking individual dependencies. +> +> If these conditions are not met (or the implementation does not track them), it MUST fall back to dependency-based revalidation (`r[cell.revalidate]`). + +#### Examples (non-normative) + +Dependency graph with per-input-kind durabilities and derived effective durability: + +```aasvg +.---------------------------. +| Input kinds (durability) | +| SourceFile (LOW) | +| CargoToml (MEDIUM) | +| StdLib (HIGH) | +'-------------+-------------' + | + v +.---------------------------. +| q_cfg (eff = MEDIUM) | +| depends on: CargoToml | +'-------------+-------------' + | + v +.---------------------------. +| q_parse (eff = LOW) | +| depends on: SourceFile, | +| q_cfg | +'---------------------------' +``` + +If only `SourceFile` changes (LOW), `q_cfg` can often be treated as still valid without walking its dependencies, because it depends only on inputs with durability `>= MEDIUM`. +`q_parse` cannot, because its effective durability is LOW. + +Timeline sketch illustrating the durability watermark: + +```aasvg +revision: R0 R1 R2 +change: - SourceFile(LOW) CargoToml(MEDIUM) +last_changed_at_at_or_above[MEDIUM]: + R0 R0 R2 + +q_cfg.verified_at: R0 (skip dep walk) must revalidate +q_parse.verified_at: R0 must revalidate must revalidate +``` + +### Cell state and visibility + +Each derived query `(kind, key)` conceptually has a memo entry (“cell”) with: + +- the last computed value (if any), +- a dependency set (from the last successful computation), +- `verified_at`: the most recent revision at which the cached value was confirmed valid with respect to its recorded dependencies, +- `changed_at`: the most recent revision at which the cached value changed (per `r[equality.relation]`). + +r[revision.early-cutoff] +When a derived query is recomputed at revision `R` and produces a value equal to the previously cached value (per `r[equality.relation]`), `changed_at` MUST NOT advance (it remains the prior `changed_at`), while `verified_at` MUST advance to `R`. + +### Revalidation + +> r[cell.revalidate] +> When accessing a cached derived value at revision `R`, if the cached value is not known-valid at `R`, the runtime MUST revalidate it by checking the stored dependencies: +> +> - If every dependency’s `changed_at` is `<=` the cached value’s `verified_at`, revalidation succeeds and the cached value MUST be returned (with `verified_at` updated to `R`). +> - Otherwise, revalidation fails and the query MUST be recomputed. + +r[cell.revalidate-missing] +If a dependency’s ingredient is not available (e.g., the kind is not registered in the current database), revalidation MUST fail and recomputation MUST be attempted. + +### Errors and poisoning + +> r[cell.poison] +> If computation fails (returns an error or panics), the runtime MUST record a failure for that `(kind, key, revision)` such that: +> +> - Subsequent accesses at the same revision return the same error without rerunning the computation. +> - After the revision advances due to an input change, a new access MAY attempt recomputation. +> +> Failures propagate through dependency edges: if a derived query attempts to read another derived query and that dependency fails, the read MUST return an error and the dependent query’s evaluation MUST fail (unless user code explicitly handles the error as data). + +### Cancellation (dropped evaluations) + +In Rust async, a computation can be *cancelled* by dropping its future. Cancellation is not the same as “the query returned an error”: it is an absence of a result. + +> r[derived.cancel] +> If an in-progress derived-query evaluation at revision `R` is cancelled before it produces a successful value or an error (e.g., the future is dropped), the implementation MUST treat it as not having produced a result: +> +> - It MUST NOT update the cached value for `(kind, key)` based on the cancelled evaluation. +> - It MUST NOT record a failure for revision `R` solely due to cancellation. +> - A subsequent access at the same revision `R` MAY retry evaluation and complete successfully or with an error. +> +> Cancellation of one caller MUST NOT force other concurrent callers to observe a cancellation error; other callers MAY still obtain a normal value or error according to the semantics above. + +> r[derived.cancel-progress] +> Cancellation MUST NOT permanently block progress for the affected `(kind, key, revision)`: +> after a cancellation, a subsequent access at the same revision MUST be able to either (a) observe a completed value/error from some other in-progress evaluation, or (b) start a fresh evaluation. + +## Invalidation semantics + +> r[dep.invalidation] +> Whenever an input record changes at revision `R`, any derived query whose dependency set includes that input record MUST be treated as stale for revisions `>= R`. +> +> Staleness is a logical property: implementations MAY propagate invalidation eagerly or lazily, but MUST ensure the revalidation rules above are upheld. + +## Cycles + +> r[cycle.detect] +> If, within a single logical derived-query evaluation (i.e., along one evaluation stack in one view), evaluation would (directly or indirectly) require evaluating the same `(kind, key)` again at the same revision, the runtime MUST report a dependency cycle error rather than deadlocking or waiting indefinitely. +> +> This requirement does not mandate detection of cross-task/cross-evaluation cycles. (Those may still deadlock and are considered a usage hazard; see `r[sharing.nonobservable]` for the “sharing is non-observable” boundary.) + +--- + +## Interned values + +Interned values provide stable identity tokens (intern IDs) for immutable records. They are exempt from snapshot freezing and revision tracking. + +### API (Rust, non-normative) + +An interned ingredient typically exposes operations like: + +```rust +impl Label { + pub fn new(db: &DB, text: String) -> picante::PicanteResult