polydat_core/iteration/source.rs
1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Data sources: typed sequences that drive workload iteration.
5//!
6//! A **source** is a data provider with identity. It knows what it yields
7//! (schema), how much it has (extent), where the consumer is (cursor),
8//! and how to partition across concurrent fibers.
9//!
10//! Sources replace the `cycles` counter as the workload iteration driver.
11//! A Polydat program declares one with the `cursor` keyword
12//! (`cursor q = range(0, N) [over <partition>]`). A host pulls from
13//! sources to drive dispatch; when a source is exhausted, the activation
14//! is done.
15//!
16//! ## Source Types
17//!
18//! - **Range**: `range(0, N)` — a finite sequence of ordinals, served by
19//! [`RangeSourceFactory`]. Replaces `cycles: N`.
20//! - **Extending**: `until_elapsed(base, min_ms[, delta])` and the other
21//! `until_*` constructors, compiled to a `CursorKind::Extending*` and
22//! served by [`ExtendingRangeSourceFactory`].
23//! - **Host-supplied**: any [`DataSourceFactory`] a host implements, such
24//! as a dataset reader whose items carry vectors or metadata; this
25//! crate ships only the range factories.
26//!
27//! ## Crate Sovereignty
28//!
29//! All source API surface lives here in `polydat-core`, re-exported by
30//! `polydat` at `polydat::iteration::source`. Host runtimes and adapters
31//! consume these types but don't define them.
32
33use std::sync::Arc;
34use std::sync::atomic::{AtomicU64, Ordering};
35
36use crate::ast::{PortType, Value};
37
38/// Whether a source item can be reconstructed from its ordinal without
39/// consulting or advancing mutable source state.
40///
41/// The default is deliberately conservative. A forward-only cursor is not
42/// sufficient evidence that an earlier item is safe to render again.
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44pub enum SourceReplayStability {
45 /// Rendering is destructive, stateful, externally mutable, or otherwise
46 /// not proven stable for repeated calls at one ordinal.
47 #[default]
48 Consumptive,
49 /// `render_item(ordinal)` is pure, total, and byte-identical for the
50 /// lifetime of the advertised source generation.
51 StableByOrdinal,
52}
53
54/// A compact description of values which can be generated without rendering
55/// an opaque source item first.
56#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
57pub enum SourceValueForm {
58 /// The source may be replayable, but the item must still be rendered.
59 #[default]
60 Opaque,
61 /// The yielded scalar value is the ordinal itself. A typed consumer may
62 /// apply its declared narrowing or wrapping conversion while vectorizing.
63 Ordinal,
64}
65
66/// Runtime source capability used by offset-stamped batch plans.
67///
68/// `generation` must change whenever rendering the same ordinal may produce a
69/// different value. Activation identity remains executor-owned because a
70/// phase rewind can intentionally replay one unchanged source generation as a
71/// new logical evaluation.
72#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
73pub struct SourceReplayContract {
74 /// Whether rendering an ordinal again gives the same value.
75 pub stability: SourceReplayStability,
76 /// The generation of the source's values; changes when an ordinal may render differently.
77 pub generation: u64,
78 /// What a value is: an ordinal, or opaque.
79 pub value_form: SourceValueForm,
80}
81
82impl SourceReplayContract {
83 /// The contract of a source that is consumed as it advances: no replay.
84 pub const fn consumptive() -> Self {
85 Self {
86 stability: SourceReplayStability::Consumptive,
87 generation: 0,
88 value_form: SourceValueForm::Opaque,
89 }
90 }
91
92 /// The contract of a source stable by ordinal in `generation`.
93 pub const fn stable_ordinal(generation: u64) -> Self {
94 Self {
95 stability: SourceReplayStability::StableByOrdinal,
96 generation,
97 value_form: SourceValueForm::Ordinal,
98 }
99 }
100
101 /// Whether any owned ordinal renders again without advancing the source.
102 pub const fn is_stable_by_ordinal(self) -> bool {
103 matches!(self.stability, SourceReplayStability::StableByOrdinal)
104 }
105
106 /// True when the source ordinal is simultaneously a replay key, scalar
107 /// value, packet number, and low-order lane clock.
108 pub const fn is_perfect_ordinal(self) -> bool {
109 self.is_stable_by_ordinal() && matches!(self.value_form, SourceValueForm::Ordinal)
110 }
111}
112
113/// A single item yielded by a source.
114#[derive(Clone, Debug)]
115pub struct SourceItem {
116 /// Position in the source sequence.
117 pub ordinal: u64,
118 /// Named field values. Empty for range sources (ordinal IS the data).
119 /// For dataset sources: `[("vector", Value::Json(...)), ("metadata", Value::U64(...))]`.
120 pub fields: Vec<(String, Value)>,
121}
122
123impl SourceItem {
124 /// Create a range item (ordinal only, no fields).
125 pub fn ordinal(ordinal: u64) -> Self {
126 Self {
127 ordinal,
128 fields: Vec::new(),
129 }
130 }
131
132 /// Create an item with ordinal and named fields.
133 pub fn with_fields(ordinal: u64, fields: Vec<(String, Value)>) -> Self {
134 Self { ordinal, fields }
135 }
136
137 /// Get a field value by name.
138 pub fn field(&self, name: &str) -> Option<&Value> {
139 self.fields.iter().find(|(n, _)| n == name).map(|(_, v)| v)
140 }
141}
142
143/// Schema describing what a source yields.
144#[derive(Clone, Debug)]
145pub struct SourceSchema {
146 /// Source name as declared in the Polydat graph.
147 pub name: String,
148 /// Field names and types available for projection (e.g., `ordinal: U64`, `vector: Json`).
149 pub projections: Vec<(String, PortType)>,
150 /// Known extent, if finite. `None` for infinite sources.
151 ///
152 /// `None` may also mean the extent is computable but only at runtime
153 /// (e.g., the cursor's `range(...)` bounds depend on iteration-variable
154 /// externs). In that case `extent_outputs` carries the kernel output
155 /// names whose values yield `[start, end)` once externs are bound.
156 pub extent: Option<u64>,
157 /// Optional aux output names `(start, end)` that the runtime can pull
158 /// from the kernel after externs are populated to compute extent. Set
159 /// when `range(...)` bounds are non-literal (e.g., wire-bound dataset
160 /// function calls). The compiler also writes back to `extent` if both
161 /// values fold to constants at compile time.
162 pub extent_outputs: Option<(String, String)>,
163 /// Optional cursor limit clamp, taken from
164 /// `CompileOptions::cursor_limit` (a host's `--limit`-style option).
165 /// Applied after runtime extent evaluation.
166 pub extent_limit: Option<u64>,
167 /// What kind of cursor this is. Default `Range`, the bounded
168 /// `range(start, end)` cursor; the `Extending*` variants declare a
169 /// runtime-extending cursor under a wall-clock, count, or
170 /// predicate policy. A host picks [`ExtendingRangeSourceFactory`]
171 /// for those, and `cursor_over_partitions` treats them as
172 /// open-extent when it resolves an `over` clause.
173 pub cursor_kind: CursorKind,
174 /// SRD 71: name of the kernel output that carries the
175 /// partition-narrowing source (the `over <expr>` clause on
176 /// the cursor declaration). `cursor_over_partitions` pulls it and
177 /// resolves it through `cursor_partition::resolve_over` into the
178 /// full partition list: a spec string or a `PartitionSpec`
179 /// resolves against the cursor's extent, a `Partition` or a
180 /// `PartitionList` is re-projected onto it, and `Value::None`
181 /// yields no partitions. A host, such as activation setup,
182 /// selects one partition and writes it with `set_cursor`.
183 ///
184 /// `None` means the cursor was declared without an `over`
185 /// clause; the cursor uses its full declared extent.
186 pub partition_output: Option<String>,
187 /// The partitions the `over` clause denotes, resolved by the
188 /// compiler when the clause is a literal spec and the extent is
189 /// known at build (engine_parity.md, step 3). A host reads them
190 /// without evaluating anything; `cursor_over_partitions` returns
191 /// them without a pull. `None` when the clause or the extent is
192 /// only known at run time, or the cursor has no `over` clause.
193 pub partitions: Option<Vec<crate::iteration::cursor_partition::Partition>>,
194}
195
196/// Cursor-construction discriminator. Set by the Polydat compiler
197/// when it recognises a cursor's constructor expression
198/// (`range(...)`, `until_elapsed(...)`, etc.); read by the
199/// executor at phase setup to instantiate the matching
200/// data-source factory + policy.
201///
202/// Each `Extending*` variant carries the Polydat output names the
203/// runtime should pull at phase setup to resolve the policy's
204/// parameters (same pattern as `extent_outputs` for `range`).
205/// `delta_output` is optional — when `None`, the cursor's base
206/// is used as the extension delta.
207#[derive(Clone, Debug, Default)]
208pub enum CursorKind {
209 /// Bounded `range(start, end)` cursor. Default — keeps
210 /// every existing call site working.
211 #[default]
212 Range,
213 /// `until_elapsed(base, min_ms[, delta])`.
214 /// Extends while elapsed_ms < min_ms.
215 ExtendingTimed {
216 /// The output holding the minimum elapsed milliseconds.
217 min_ms_output: String,
218 /// The output holding the extension step, if one was given.
219 delta_output: Option<String>,
220 },
221 /// `until_passes(base, min_passes[, delta])`.
222 /// Extends while completed passes < min_passes.
223 ExtendingPasses {
224 /// The output holding the minimum number of passes.
225 min_passes_output: String,
226 /// The output holding the extension step, if one was given.
227 delta_output: Option<String>,
228 },
229 /// `until_count(base, min_count[, delta])`.
230 /// Extends while raw consumed count < min_count.
231 ExtendingCount {
232 /// The output holding the minimum consumed count.
233 min_count_output: String,
234 /// The output holding the extension step, if one was given.
235 delta_output: Option<String>,
236 },
237 /// `until_elapsed_and_passes(base, min_ms, min_passes[, delta])`.
238 /// AND: stops when EITHER target is reached. Extends while
239 /// BOTH conditions are still below target.
240 ExtendingElapsedAndPasses {
241 /// The output holding the minimum elapsed milliseconds.
242 min_ms_output: String,
243 /// The output holding the minimum number of passes.
244 min_passes_output: String,
245 /// The output holding the extension step, if one was given.
246 delta_output: Option<String>,
247 },
248 /// `until_elapsed_or_passes(base, min_ms, min_passes[, delta])`.
249 /// OR: stops only when BOTH targets are reached. Extends
250 /// while EITHER condition is still below target.
251 ExtendingElapsedOrPasses {
252 /// The output holding the minimum elapsed milliseconds.
253 min_ms_output: String,
254 /// The output holding the minimum number of passes.
255 min_passes_output: String,
256 /// The output holding the extension step, if one was given.
257 delta_output: Option<String>,
258 },
259}
260
261/// Consumption API for data sources. One instance per fiber.
262///
263/// The interaction model has two phases:
264///
265/// 1. **Reserve** — `reserve(stride)` atomically claims a range of
266/// ordinals via CAS on the shared cursor. This touches shared
267/// state but is instantaneous (one atomic op). Returns `None`
268/// when the source is globally exhausted.
269///
270/// 2. **Render** — the fiber uses the reserved range with its own
271/// Polydat instance to produce field values. No shared state, no
272/// contention between fibers. For range sources, rendering is
273/// trivial (ordinal IS the data). For dataset sources, rendering
274/// reads vectors/metadata from mmap'd storage.
275///
276/// The `next_chunk` convenience method combines both phases. Use
277/// `reserve` directly when the rendering is handled by the
278/// executor's Polydat fiber.
279pub trait DataSource: Send {
280 /// Atomically reserve up to `stride` ordinals from the source.
281 ///
282 /// Returns the half-open range `[start..end)` of reserved
283 /// ordinals, or `None` if the source is exhausted. The range
284 /// may be shorter than `stride` at the tail of the source.
285 ///
286 /// This is the only method that touches shared state (the
287 /// global cursor). It must be lock-free — a single CAS or
288 /// fetch_add.
289 fn reserve(&mut self, stride: usize) -> Option<std::ops::Range<u64>>;
290
291 /// Pull the next item. `None` = source exhausted.
292 fn next(&mut self) -> Option<SourceItem> {
293 let range = self.reserve(1)?;
294 Some(self.render_item(range.start))
295 }
296
297 /// Pull up to `limit` items. Combines reserve + render.
298 /// Returns fewer than `limit` only when the source is globally
299 /// exhausted. Empty vec = exhausted.
300 fn next_chunk(&mut self, limit: usize) -> Vec<SourceItem> {
301 let range = match self.reserve(limit) {
302 Some(r) => r,
303 None => return Vec::new(),
304 };
305 (range.start..range.end)
306 .map(|ordinal| self.render_item(ordinal))
307 .collect()
308 }
309
310 /// Produce a source item for a previously reserved ordinal.
311 ///
312 /// This is the fiber-local rendering step — no shared state.
313 /// For range sources: returns `SourceItem::ordinal(ordinal)`.
314 /// For dataset sources: reads vector/metadata from storage.
315 fn render_item(&self, ordinal: u64) -> SourceItem;
316
317 /// Known extent, if finite.
318 fn extent(&self) -> Option<u64>;
319
320 /// Items consumed so far (for progress reporting).
321 fn consumed(&self) -> u64;
322
323 /// The schema of items this source yields.
324 fn schema(&self) -> &SourceSchema;
325
326 /// Replay/addressability capability for offset-stamped batch execution.
327 /// External source implementations inherit the safe consumptive default
328 /// until they explicitly prove the stronger contract.
329 fn replay_contract(&self) -> SourceReplayContract {
330 SourceReplayContract::consumptive()
331 }
332}
333
334/// Factory that creates per-fiber `DataSource` readers.
335///
336/// Holds shared state (atomic cursor, partition pool) that's
337/// distributed across readers. Each fiber gets its own reader.
338///
339/// ## Dispatch model
340///
341/// The **stride** is the stanza length — the number of source items
342/// a fiber acquires as an atomic unit. One stanza of ops processes
343/// one stride of source items. Strides are inseparable: a fiber
344/// that acquires a stride processes all items before acquiring the
345/// next.
346///
347/// The default implementation (`RangeSourceFactory`) uses a shared
348/// atomic cursor — all fibers pull strides from the same counter,
349/// producing natural monotonic striping. This is correct for range
350/// sources where items are independent ordinals.
351///
352/// For dataset sources with locality benefits (mmap prefetch),
353/// factories can implement partitioned allocation: each fiber gets
354/// a pre-assigned range of strides, and when exhausted, steals
355/// strides from a shared pool. The stride is the minimum unit of
356/// work stealing — a fiber never steals partial stanzas.
357pub trait DataSourceFactory: Send + Sync {
358 /// Create a new reader for a fiber.
359 fn create_reader(&self) -> Box<dyn DataSource>;
360
361 /// Schema for all readers from this factory.
362 fn schema(&self) -> &SourceSchema;
363
364 /// Global items consumed across all readers (for progress reporting).
365 fn global_consumed(&self) -> u64;
366
367 /// Known extent, if finite. Same as schema().extent but avoids clone.
368 fn global_extent(&self) -> Option<u64> {
369 self.schema().extent
370 }
371
372 /// Replay/addressability capability shared by readers from this factory.
373 fn replay_contract(&self) -> SourceReplayContract {
374 SourceReplayContract::consumptive()
375 }
376
377 /// Rewind the factory's shared cursor to the start so a
378 /// subsequent `create_reader()` produces a fresh stream
379 /// covering the same ordinal range: what a host that re-runs a
380 /// source between poll rounds calls after each round exhausts it.
381 ///
382 /// Default impl: returns `false` to signal the factory
383 /// doesn't support rewinding. Factories that DO support it
384 /// (RangeSourceFactory, ExtendingRangeSourceFactory)
385 /// override and reset their internal cursor / extent
386 /// state. A host that needs to rewind should reject a factory
387 /// that returns `false` with a clear diagnostic rather than
388 /// complete silently after the first round.
389 fn rewind_for_poll(&self) -> bool {
390 false
391 }
392}
393
394// =========================================================================
395// RangeSource: finite sequence of ordinals
396// =========================================================================
397
398/// Factory for range sources. Shared atomic cursor distributes
399/// ordinals across fibers.
400pub struct RangeSourceFactory {
401 cursor: Arc<AtomicU64>,
402 end: u64,
403 schema: SourceSchema,
404}
405
406impl RangeSourceFactory {
407 /// Create a range source from `[start, end)`.
408 pub fn new(start: u64, end: u64) -> Self {
409 Self {
410 cursor: Arc::new(AtomicU64::new(start)),
411 end,
412 schema: SourceSchema {
413 name: "_range".into(),
414 projections: vec![("ordinal".into(), PortType::U64)],
415 extent: Some(end.saturating_sub(start)),
416 extent_outputs: None,
417 extent_limit: None,
418 cursor_kind: CursorKind::Range,
419 partition_output: None,
420 partitions: None,
421 },
422 }
423 }
424
425 /// Create a range source with a named schema.
426 pub fn named(name: &str, start: u64, end: u64) -> Self {
427 let mut factory = Self::new(start, end);
428 factory.schema.name = name.to_string();
429 factory
430 }
431}
432
433impl DataSourceFactory for RangeSourceFactory {
434 fn create_reader(&self) -> Box<dyn DataSource> {
435 Box::new(RangeSource {
436 cursor: self.cursor.clone(),
437 end: self.end,
438 consumed: 0,
439 schema: self.schema.clone(),
440 })
441 }
442
443 fn schema(&self) -> &SourceSchema {
444 &self.schema
445 }
446
447 fn global_consumed(&self) -> u64 {
448 let pos = self.cursor.load(Ordering::Relaxed);
449 let start = self.end.saturating_sub(self.schema.extent.unwrap_or(0));
450 pos.saturating_sub(start)
451 .min(self.schema.extent.unwrap_or(u64::MAX))
452 }
453
454 fn replay_contract(&self) -> SourceReplayContract {
455 SourceReplayContract::stable_ordinal(0)
456 }
457
458 fn rewind_for_poll(&self) -> bool {
459 // Reset the shared atomic cursor back to the
460 // start-of-range. SRD-75 phase-poll uses this between
461 // iterations: each iteration covers ordinals
462 // `[start, end)` afresh, so the workload's ops see the
463 // same cycle space per iteration. With concurrency=1
464 // (mandated by phase-poll), there's a single fiber
465 // and no race on the reset.
466 let start = self.end.saturating_sub(self.schema.extent.unwrap_or(0));
467 self.cursor.store(start, Ordering::Relaxed);
468 true
469 }
470}
471
472/// Per-fiber range reader. Pulls ordinals from a shared atomic cursor.
473struct RangeSource {
474 cursor: Arc<AtomicU64>,
475 end: u64,
476 consumed: u64,
477 schema: SourceSchema,
478}
479
480impl DataSource for RangeSource {
481 fn reserve(&mut self, stride: usize) -> Option<std::ops::Range<u64>> {
482 let base = self.cursor.fetch_add(stride as u64, Ordering::Relaxed);
483 if base >= self.end {
484 return None;
485 }
486 let actual_end = (base + stride as u64).min(self.end);
487 let count = actual_end - base;
488 self.consumed += count;
489 Some(base..actual_end)
490 }
491
492 fn render_item(&self, ordinal: u64) -> SourceItem {
493 SourceItem::ordinal(ordinal)
494 }
495
496 fn extent(&self) -> Option<u64> {
497 self.schema.extent
498 }
499
500 fn consumed(&self) -> u64 {
501 self.consumed
502 }
503
504 fn schema(&self) -> &SourceSchema {
505 &self.schema
506 }
507
508 fn replay_contract(&self) -> SourceReplayContract {
509 SourceReplayContract::stable_ordinal(0)
510 }
511}
512
513// =========================================================================
514// ExtendingRangeSource: runtime-growable extent
515// =========================================================================
516
517/// Snapshot of cursor state passed to an [`ExtensionPolicy`]
518/// at each end-reach decision point. Policies are pure
519/// predicates over this context — they don't carry their own
520/// clocks or counters.
521#[derive(Clone, Copy, Debug)]
522pub struct ExtensionContext {
523 /// Wall-clock milliseconds since the source factory was
524 /// constructed (typically phase start).
525 pub elapsed_ms: u64,
526 /// Global ordinals consumed so far — `cursor.load() - start`.
527 /// Pass count is `consumed / base`.
528 pub consumed: u64,
529 /// The `base` chunk size declared by the cursor. Used by
530 /// policies that reason in passes rather than raw counts.
531 pub base: u64,
532}
533
534impl ExtensionContext {
535 /// Convenience: integer pass count (consumed / base).
536 /// Returns 0 when `base == 0` (degenerate cursor).
537 pub fn passes(&self) -> u64 {
538 self.consumed.checked_div(self.base).unwrap_or(0)
539 }
540}
541
542/// Policy that decides whether to extend an
543/// `ExtendingRangeSource` when its current end is reached.
544///
545/// Implementations are pure predicates over the
546/// [`ExtensionContext`] — no internal state, no side effects.
547/// The source provides elapsed time and consumed counts; the
548/// policy returns `Some(delta)` to grow the extent or `None`
549/// to terminate. Cheap-and-possibly-duplicated call contract
550/// (concurrent fibers may invoke under racing end-reach; only
551/// one CAS-wins extension takes effect per round).
552pub trait ExtensionPolicy: Send + Sync {
553 /// Decide how to extend (if at all) given the current
554 /// cursor context.
555 fn next_extension(&self, ctx: &ExtensionContext) -> Option<u64>;
556}
557
558/// Factory for ExtendingRangeSource. Differs from
559/// [`RangeSourceFactory`] in that `end` is an atomic that the
560/// extension policy may grow over the lifetime of the phase.
561/// `global_extent()` returns the CURRENT end so phase-status
562/// displays reflect any growth honestly.
563pub struct ExtendingRangeSourceFactory {
564 cursor: Arc<AtomicU64>,
565 end: Arc<AtomicU64>,
566 start: u64,
567 /// Per-pass chunk size — also the default extension delta
568 /// when the policy reports "continue". Exposed to the
569 /// policy via `ExtensionContext::base` so pass-count
570 /// predicates work.
571 base: u64,
572 /// SRD 71 — hard upper bound on growth. When a cursor is
573 /// narrowed by a partition (`until_elapsed(...) over p`),
574 /// the partition's end ordinal caps the extension: the
575 /// policy keeps making its time / pass / count decisions,
576 /// but the source terminates the moment the partition is
577 /// exhausted, whichever comes first. `None` = unbounded
578 /// (the policy alone decides).
579 max_end: Option<u64>,
580 /// Wall-clock baseline. Captured at factory construction
581 /// so per-phase factories yield per-phase elapsed numbers
582 /// without external clock plumbing.
583 started: std::time::Instant,
584 policy: Arc<dyn ExtensionPolicy>,
585 schema: SourceSchema,
586}
587
588impl ExtendingRangeSourceFactory {
589 /// Build with an initial extent `[start, start + initial_extent)`.
590 /// The extension policy is consulted only when the cursor
591 /// reaches the end — the first stride consumed produces
592 /// indices in the initial range.
593 pub fn new(
594 name: &str,
595 start: u64,
596 initial_extent: u64,
597 policy: Arc<dyn ExtensionPolicy>,
598 ) -> Self {
599 let end = start.saturating_add(initial_extent);
600 Self {
601 cursor: Arc::new(AtomicU64::new(start)),
602 end: Arc::new(AtomicU64::new(end)),
603 start,
604 base: initial_extent,
605 max_end: None,
606 started: std::time::Instant::now(),
607 policy,
608 schema: SourceSchema {
609 name: name.to_string(),
610 projections: vec![("ordinal".into(), PortType::U64)],
611 extent: Some(initial_extent),
612 extent_outputs: None,
613 cursor_kind: CursorKind::Range,
614 extent_limit: None,
615 partition_output: None,
616 partitions: None,
617 },
618 }
619 }
620
621 /// Cap growth at `max_end` (absolute ordinal, exclusive) —
622 /// the partition-narrowing bound from SRD 71. The initial
623 /// extent is clamped too, so a base chunk larger than the
624 /// partition never reserves past it.
625 pub fn bounded(mut self, max_end: u64) -> Self {
626 self.max_end = Some(max_end);
627 let clamped = self.end.load(Ordering::Acquire).min(max_end);
628 self.end.store(clamped, Ordering::Release);
629 self
630 }
631}
632
633impl DataSourceFactory for ExtendingRangeSourceFactory {
634 fn create_reader(&self) -> Box<dyn DataSource> {
635 Box::new(ExtendingRangeSource {
636 cursor: self.cursor.clone(),
637 end: self.end.clone(),
638 policy: self.policy.clone(),
639 start: self.start,
640 base: self.base,
641 max_end: self.max_end,
642 started: self.started,
643 consumed: 0,
644 schema: self.schema.clone(),
645 })
646 }
647
648 fn schema(&self) -> &SourceSchema {
649 &self.schema
650 }
651
652 fn global_consumed(&self) -> u64 {
653 let pos = self.cursor.load(Ordering::Relaxed);
654 pos.saturating_sub(self.start)
655 }
656
657 fn global_extent(&self) -> Option<u64> {
658 // Live extent — readers / status displays see growth.
659 Some(self.end.load(Ordering::Acquire).saturating_sub(self.start))
660 }
661
662 fn replay_contract(&self) -> SourceReplayContract {
663 SourceReplayContract::stable_ordinal(0)
664 }
665}
666
667/// Per-fiber reader for an extending range source. Reservation
668/// uses CAS so concurrent fibers race cleanly without lock
669/// contention; on end-reach the policy is consulted (possibly
670/// duplicated under concurrent end-reach), and the end atomic
671/// is grown via a single CAS (only one extension takes effect
672/// per round; losers retry and see the new end).
673struct ExtendingRangeSource {
674 cursor: Arc<AtomicU64>,
675 end: Arc<AtomicU64>,
676 policy: Arc<dyn ExtensionPolicy>,
677 start: u64,
678 base: u64,
679 /// SRD 71 partition cap — see
680 /// [`ExtendingRangeSourceFactory::bounded`].
681 max_end: Option<u64>,
682 started: std::time::Instant,
683 consumed: u64,
684 schema: SourceSchema,
685}
686
687impl DataSource for ExtendingRangeSource {
688 fn reserve(&mut self, stride: usize) -> Option<std::ops::Range<u64>> {
689 loop {
690 let cur = self.cursor.load(Ordering::Acquire);
691 let end = self.end.load(Ordering::Acquire);
692 if cur < end {
693 // Try to claim [cur, min(cur+stride, end)).
694 let target = (cur.saturating_add(stride as u64)).min(end);
695 match self
696 .cursor
697 .compare_exchange(cur, target, Ordering::AcqRel, Ordering::Acquire)
698 {
699 Ok(_) => {
700 let count = target - cur;
701 self.consumed += count;
702 return Some(cur..target);
703 }
704 Err(_) => continue, // raced; retry
705 }
706 }
707 // SRD 71: a partition-bound cursor terminates the
708 // moment the partition is exhausted, whether or not
709 // the policy's time / pass / count target was
710 // reached — no policy consultation past the cap.
711 if let Some(max) = self.max_end
712 && end >= max
713 {
714 return None;
715 }
716 // Cursor has caught up to end. Consult policy with
717 // a snapshot of the current state. Each fiber that
718 // races to this point gets its own context read;
719 // duplicate consultations are idempotent for pure
720 // predicates.
721 let ctx = ExtensionContext {
722 elapsed_ms: self.started.elapsed().as_millis() as u64,
723 consumed: end.saturating_sub(self.start),
724 base: self.base,
725 };
726 match self.policy.next_extension(&ctx) {
727 Some(delta) if delta > 0 => {
728 // CAS the end forward, clamped at the
729 // partition cap when one is set. If someone
730 // else extended ahead of us, that's fine —
731 // our duplicate policy consultation is
732 // harmless and the new end is at least as
733 // far as ours would have been.
734 let mut new_end = end.saturating_add(delta);
735 if let Some(max) = self.max_end {
736 new_end = new_end.min(max);
737 }
738 if new_end == end {
739 return None;
740 }
741 let _ = self.end.compare_exchange(
742 end,
743 new_end,
744 Ordering::AcqRel,
745 Ordering::Acquire,
746 );
747 continue;
748 }
749 _ => return None,
750 }
751 }
752 }
753
754 fn render_item(&self, ordinal: u64) -> SourceItem {
755 SourceItem::ordinal(ordinal)
756 }
757
758 fn extent(&self) -> Option<u64> {
759 // Live extent — same convention as the factory's
760 // `global_extent`: subscribers see the current ceiling
761 // even after it grows.
762 Some(self.end.load(Ordering::Acquire).saturating_sub(self.start))
763 }
764
765 fn consumed(&self) -> u64 {
766 self.consumed
767 }
768
769 fn schema(&self) -> &SourceSchema {
770 &self.schema
771 }
772
773 fn replay_contract(&self) -> SourceReplayContract {
774 SourceReplayContract::stable_ordinal(0)
775 }
776}
777
778// =========================================================================
779// Cursors: provenance-driven cursor targeting
780// =========================================================================
781
782/// A cursor target: a DataSource reader paired with its Polydat input index.
783struct CursorTarget {
784 /// The DataSource reader that provides values for this cursor.
785 reader: Box<dyn DataSource>,
786 /// The Polydat input index where the cursor's ordinal is injected.
787 input_index: usize,
788 /// Source name (for diagnostics).
789 source_name: String,
790 /// Stable process-independent identity used in ordinal packet stamps.
791 stream_id: u64,
792}
793
794/// Ownership token for one range reserved from a perfect ordinal source.
795///
796/// The token contains no reader borrow or rendered payload because the source
797/// contract proves that the scalar value is the ordinal itself. Moving this
798/// value transfers responsibility for every ordinal in `range`; the batch
799/// executor must drain all of them or process the remainder through its scalar
800/// recovery path. The shared source cursor is never rewound.
801#[derive(Debug)]
802pub struct OrdinalBatchLease {
803 source_name: String,
804 stream_id: u64,
805 source_generation: u64,
806 input_index: usize,
807 sequence: u64,
808 range: std::ops::Range<u64>,
809}
810
811impl OrdinalBatchLease {
812 /// The cursor the lease reserves from.
813 pub fn source_name(&self) -> &str {
814 &self.source_name
815 }
816
817 /// The stream the ordinals belong to.
818 pub const fn stream_id(&self) -> u64 {
819 self.stream_id
820 }
821
822 /// The generation of the source's values.
823 pub const fn source_generation(&self) -> u64 {
824 self.source_generation
825 }
826
827 /// The kernel input the cursor's ordinal is written to.
828 pub const fn input_index(&self) -> usize {
829 self.input_index
830 }
831
832 /// The lease's position in reservation order.
833 pub const fn sequence(&self) -> u64 {
834 self.sequence
835 }
836
837 /// The ordinals reserved.
838 pub fn range(&self) -> std::ops::Range<u64> {
839 self.range.clone()
840 }
841
842 /// How many ordinals the lease holds.
843 pub const fn len_u64(&self) -> u64 {
844 self.range.end - self.range.start
845 }
846
847 /// Whether the lease holds no ordinal.
848 pub const fn is_empty(&self) -> bool {
849 self.range.start == self.range.end
850 }
851}
852
853#[derive(Clone, Debug, PartialEq, Eq)]
854/// Why a cursor batch could not be reserved.
855pub enum CursorBatchError {
856 /// No ordinals were asked for.
857 ZeroDemand,
858 /// The reservation names several targets; a batch drives one.
859 RequiresSingleTarget {
860 /// The targets named.
861 targets: usize,
862 },
863 /// The source is not a perfect ordinal stream.
864 SourceNotPerfectOrdinal {
865 /// The source's name.
866 source: String,
867 },
868}
869
870impl std::fmt::Display for CursorBatchError {
871 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
872 match self {
873 Self::ZeroDemand => f.write_str("ordinal batch demand must be greater than zero"),
874 Self::RequiresSingleTarget { targets } => write!(
875 f,
876 "Tier-1 ordinal batching requires exactly one cursor target, found {targets}"
877 ),
878 Self::SourceNotPerfectOrdinal { source } => write!(
879 f,
880 "source '{source}' does not declare the perfect ordinal replay contract"
881 ),
882 }
883 }
884}
885
886impl std::error::Error for CursorBatchError {}
887
888/// Provenance-driven advancer that targets only the cursor nodes
889/// relevant to a specific set of output fields.
890///
891/// Built by a host from the output names it will read, tracing Polydat
892/// provenance from those fields back to root cursor nodes. Only those cursors
893/// advance — unused cursors are left untouched.
894pub struct Cursors {
895 targets: Vec<CursorTarget>,
896 /// Last items read from each target (for injecting into Polydat state).
897 last_items: Vec<Option<SourceItem>>,
898 /// Total advances performed.
899 advances: u64,
900 /// Reservation order for owned ordinal leases from this cursor set.
901 next_batch_sequence: u64,
902}
903
904impl Cursors {
905 /// Build an advancer from a Polydat program, a set of output field names,
906 /// and a map of source name → DataSourceFactory.
907 ///
908 /// Traces provenance: for each field, finds the output node, gets its
909 /// input provenance bitmask, and identifies which inputs are cursor
910 /// ordinals (matching `{source}__ordinal` pattern). Creates a reader
911 /// for each targeted source.
912 pub fn for_fields(
913 program: &crate::kernel::PolydatProgram,
914 field_names: &[&str],
915 source_factories: &std::collections::HashMap<String, Arc<dyn DataSourceFactory>>,
916 ) -> Self {
917 // Collect the union of input provenance for all referenced
918 // fields — exact at any input count (multi-word ProvMask;
919 // the former one-word form over-matched cursor inputs >= 63).
920 let mut combined_provenance = crate::kernel::ProvMask::empty();
921 for name in field_names {
922 if let Some((node_idx, _)) = program.resolve_output(name)
923 && let Some(prov) = program.input_provenance_for(node_idx)
924 {
925 combined_provenance.union_with(prov);
926 }
927 }
928
929 // Find cursor inputs in the provenance
930 let input_names = program.input_names();
931 let mut targets = Vec::new();
932 let mut seen_sources = std::collections::HashSet::new();
933
934 for (idx, input_name) in input_names.iter().enumerate() {
935 if !combined_provenance.contains(idx) {
936 continue;
937 }
938
939 // Check if this input is a source projection ({source}__ordinal)
940 if let Some(source_name) = input_name.strip_suffix("__ordinal") {
941 if seen_sources.contains(source_name) {
942 continue;
943 }
944 seen_sources.insert(source_name.to_string());
945
946 if let Some(factory) = source_factories.get(source_name) {
947 let stream_id =
948 xxhash_rust::xxh3::xxh3_64(format!("{source_name}@{idx}").as_bytes());
949 targets.push(CursorTarget {
950 reader: factory.create_reader(),
951 input_index: idx,
952 source_name: source_name.to_string(),
953 stream_id,
954 });
955 }
956 }
957 }
958
959 let target_count = targets.len();
960 Cursors {
961 targets,
962 last_items: vec![None; target_count],
963 advances: 0,
964 next_batch_sequence: 0,
965 }
966 }
967
968 /// Reserve one owned batch from a single perfect ordinal cursor.
969 ///
970 /// This is the source-side entry point for Tier-1 SIMD execution. It is
971 /// intentionally unavailable for multiple cursor targets or sources that
972 /// merely happen to be monotonic: only the explicit perfect-ordinal
973 /// contract permits payload-free replay from the returned range.
974 pub fn reserve_ordinal_batch(
975 &mut self,
976 demand: usize,
977 ) -> Result<Option<OrdinalBatchLease>, CursorBatchError> {
978 if demand == 0 {
979 return Err(CursorBatchError::ZeroDemand);
980 }
981 if self.targets.len() != 1 {
982 return Err(CursorBatchError::RequiresSingleTarget {
983 targets: self.targets.len(),
984 });
985 }
986
987 let target = &mut self.targets[0];
988 let contract = target.reader.replay_contract();
989 if !contract.is_perfect_ordinal() {
990 return Err(CursorBatchError::SourceNotPerfectOrdinal {
991 source: target.source_name.clone(),
992 });
993 }
994 let Some(range) = target.reader.reserve(demand) else {
995 return Ok(None);
996 };
997 let claimed = range.end - range.start;
998 let lease = OrdinalBatchLease {
999 source_name: target.source_name.clone(),
1000 stream_id: target.stream_id,
1001 source_generation: contract.generation,
1002 input_index: target.input_index,
1003 sequence: self.next_batch_sequence,
1004 range,
1005 };
1006 self.next_batch_sequence = self.next_batch_sequence.wrapping_add(1);
1007 self.advances = self.advances.saturating_add(claimed);
1008 Ok(Some(lease))
1009 }
1010
1011 /// Advance all targeted cursors. Returns `false` if any targeted
1012 /// cursor is exhausted (no more data).
1013 ///
1014 /// After advancing, the new ordinals and field projections are
1015 /// available via `inject_into_state()`.
1016 pub fn advance(&mut self) -> bool {
1017 for (i, target) in self.targets.iter_mut().enumerate() {
1018 match target.reader.next() {
1019 Some(item) => {
1020 self.last_items[i] = Some(item);
1021 }
1022 None => return false, // this cursor exhausted
1023 }
1024 }
1025 self.advances += 1;
1026 true
1027 }
1028
1029 /// Inject the current cursor values into a Polydat state.
1030 ///
1031 /// Sets each cursor's ordinal at its input index. Field projections
1032 /// are not written here; a host reads them from `last_items()`.
1033 pub fn inject_into_state(&self, state: &mut crate::kernel::PolydatState) {
1034 for (i, target) in self.targets.iter().enumerate() {
1035 if let Some(ref item) = self.last_items[i] {
1036 state.set_input(target.input_index, crate::ast::Value::U64(item.ordinal));
1037 // Field projections (e.g. base__vector) are the host's to
1038 // write from `last_items()`; only the ordinal lands here.
1039 }
1040 }
1041 }
1042
1043 /// The last items read from all targets, for a host to project fields from.
1044 pub fn last_items(&self) -> &[Option<SourceItem>] {
1045 &self.last_items
1046 }
1047
1048 /// Known extent of the driving cursor (smallest among targeted
1049 /// cursors with known extent). Used for progress reporting.
1050 pub fn extent(&self) -> Option<u64> {
1051 self.targets.iter().filter_map(|t| t.reader.extent()).min()
1052 }
1053
1054 /// Total advances performed so far.
1055 pub fn consumed(&self) -> u64 {
1056 self.advances
1057 }
1058
1059 /// Number of targeted cursors.
1060 pub fn target_count(&self) -> usize {
1061 self.targets.len()
1062 }
1063
1064 /// Whether this advancer has any targets.
1065 pub fn is_empty(&self) -> bool {
1066 self.targets.is_empty()
1067 }
1068}
1069
1070// =========================================================================
1071// Built-in ExtensionPolicy implementations
1072// =========================================================================
1073//
1074// Each policy is a pure predicate over `ExtensionContext` and
1075// returns `Some(delta)` when the cursor should continue or
1076// `None` when it should stop. The delta is independent of the
1077// stop condition — workloads can extend in chunks unrelated to
1078// the cursor's base size (e.g. base=10000 but delta=1000 to
1079// check the condition more often).
1080
1081/// Extend while `ctx.elapsed_ms < min_ms`, projecting the
1082/// remaining work from the observed rate and committing it in
1083/// one batch.
1084///
1085/// Per call: estimate `repeats = floor((consumed * remaining_ms
1086/// / elapsed_ms) / base)` — the number of base-sized passes
1087/// needed to fill the time remaining at the current rate. Apply
1088/// a 5% under-bias (`repeats * 95 / 100`) so successive
1089/// extensions converge from below the target rather than
1090/// overshooting once. Commit `repeats * base` cycles.
1091///
1092/// The under-bias gives a geometric convergence: each call
1093/// covers ~95% of the time remaining, so 3–4 calls typically
1094/// land within 0.01% of the time budget. The first call (no
1095/// rate signal yet — `elapsed_ms == 0` or `consumed == 0`)
1096/// falls back to one base chunk via the `delta` field.
1097pub struct UntilElapsedPolicy {
1098 /// The elapsed milliseconds to reach.
1099 pub min_ms: u64,
1100 /// The extension step for the first call, before a rate is known.
1101 pub delta: u64,
1102}
1103
1104impl ExtensionPolicy for UntilElapsedPolicy {
1105 fn next_extension(&self, ctx: &ExtensionContext) -> Option<u64> {
1106 if ctx.elapsed_ms >= self.min_ms {
1107 return None;
1108 }
1109 // No rate signal yet — first end-reach with elapsed time
1110 // below the clock resolution, or a degenerate empty
1111 // cursor. Step by the declared `delta` (which the
1112 // executor sets to `base` when the workload didn't
1113 // supply an explicit `delta` arg) so the next call has
1114 // a rate measurement to project from.
1115 if ctx.elapsed_ms == 0 || ctx.consumed == 0 {
1116 return Some(self.delta.max(1));
1117 }
1118 let remaining_ms = self.min_ms - ctx.elapsed_ms;
1119 // u128 saturating math: consumed * remaining_ms can
1120 // overflow u64 for long-running phases with high
1121 // throughput (e.g. 10^9 ops over 10^4 ms).
1122 let est_remaining =
1123 (ctx.consumed as u128).saturating_mul(remaining_ms as u128) / (ctx.elapsed_ms as u128);
1124 let biased = est_remaining.saturating_mul(95) / 100;
1125 let base = ctx.base.max(1) as u128;
1126 let repeats = biased / base;
1127 if repeats == 0 {
1128 return None;
1129 }
1130 let delta = repeats.saturating_mul(base);
1131 Some(u64::try_from(delta).unwrap_or(u64::MAX))
1132 }
1133}
1134
1135/// Extend by `delta` while `ctx.passes() < min_passes`. Passes
1136/// are whole multiples of `ctx.base`.
1137pub struct UntilPassesPolicy {
1138 /// The passes to reach.
1139 pub min_passes: u64,
1140 /// The extension step.
1141 pub delta: u64,
1142}
1143
1144impl ExtensionPolicy for UntilPassesPolicy {
1145 fn next_extension(&self, ctx: &ExtensionContext) -> Option<u64> {
1146 if ctx.passes() < self.min_passes {
1147 Some(self.delta)
1148 } else {
1149 None
1150 }
1151 }
1152}
1153
1154/// Extend by `delta` while `ctx.consumed < min_count`.
1155pub struct UntilCountPolicy {
1156 /// The consumed count to reach.
1157 pub min_count: u64,
1158 /// The extension step.
1159 pub delta: u64,
1160}
1161
1162impl ExtensionPolicy for UntilCountPolicy {
1163 fn next_extension(&self, ctx: &ExtensionContext) -> Option<u64> {
1164 if ctx.consumed < self.min_count {
1165 Some(self.delta)
1166 } else {
1167 None
1168 }
1169 }
1170}
1171
1172/// Continue (extend) while ALL child policies say continue.
1173/// Stops the moment any child policy returns `None`. The
1174/// delta is the minimum of the children's deltas — conservative
1175/// step size keeps any single condition from over-shooting its
1176/// stop point.
1177pub struct AndPolicy {
1178 /// The policies that must all ask to continue.
1179 pub policies: Vec<Arc<dyn ExtensionPolicy>>,
1180}
1181
1182impl ExtensionPolicy for AndPolicy {
1183 fn next_extension(&self, ctx: &ExtensionContext) -> Option<u64> {
1184 let mut min_delta = u64::MAX;
1185 for p in &self.policies {
1186 let d = p.next_extension(ctx)?;
1187 min_delta = min_delta.min(d);
1188 }
1189 if min_delta == u64::MAX || min_delta == 0 {
1190 None
1191 } else {
1192 Some(min_delta)
1193 }
1194 }
1195}
1196
1197/// Continue (extend) while ANY child policy says continue.
1198/// Stops only when every child policy returns `None`. The
1199/// delta is the maximum of the policies that said continue —
1200/// matches the most aggressive child still pushing forward.
1201pub struct OrPolicy {
1202 /// The policies of which any may ask to continue.
1203 pub policies: Vec<Arc<dyn ExtensionPolicy>>,
1204}
1205
1206impl ExtensionPolicy for OrPolicy {
1207 fn next_extension(&self, ctx: &ExtensionContext) -> Option<u64> {
1208 let mut max_delta: Option<u64> = None;
1209 for p in &self.policies {
1210 if let Some(d) = p.next_extension(ctx) {
1211 max_delta = Some(max_delta.map(|m| m.max(d)).unwrap_or(d));
1212 }
1213 }
1214 max_delta
1215 }
1216}
1217
1218/// Back-compat alias for the original time-only policy.
1219/// Constructs an [`UntilElapsedPolicy`] with delta = base.
1220pub struct TimeElapsedPolicy {
1221 inner: UntilElapsedPolicy,
1222}
1223
1224impl TimeElapsedPolicy {
1225 /// A policy extending in steps of `base` until `min_ms` have elapsed.
1226 pub fn new(base: u64, min_ms: u64) -> Self {
1227 Self {
1228 inner: UntilElapsedPolicy {
1229 min_ms,
1230 delta: base,
1231 },
1232 }
1233 }
1234}
1235
1236impl ExtensionPolicy for TimeElapsedPolicy {
1237 fn next_extension(&self, ctx: &ExtensionContext) -> Option<u64> {
1238 self.inner.next_extension(ctx)
1239 }
1240}
1241
1242#[cfg(test)]
1243mod tests {
1244 use super::*;
1245
1246 #[test]
1247 fn range_sources_declare_the_perfect_ordinal_replay_contract() {
1248 let factory = RangeSourceFactory::new(10, 20);
1249 assert!(factory.replay_contract().is_perfect_ordinal());
1250 let reader = factory.create_reader();
1251 assert!(reader.replay_contract().is_perfect_ordinal());
1252 let item = reader.render_item(13);
1253 assert_eq!(item.ordinal, 13);
1254 assert!(item.fields.is_empty());
1255 }
1256
1257 /// Policy that always extends by `base` — stands in for a
1258 /// time/pass policy whose target is far away, so the
1259 /// partition cap is the only thing that can stop growth.
1260 struct AlwaysExtend;
1261 impl ExtensionPolicy for AlwaysExtend {
1262 fn next_extension(&self, ctx: &ExtensionContext) -> Option<u64> {
1263 Some(ctx.base.max(1))
1264 }
1265 }
1266
1267 #[test]
1268 fn extending_source_bounded_terminates_at_partition_end() {
1269 // SRD 71: `until_*(base, ...) over p` — base-sized chunks
1270 // walk within the partition; the cap stops growth even
1271 // though the policy would keep extending. Partition
1272 // [100, 125) with base 10 → 10 + 10 + 5, then exhausted.
1273 let factory =
1274 ExtendingRangeSourceFactory::new("q", 100, 10, Arc::new(AlwaysExtend)).bounded(125);
1275 let mut reader = factory.create_reader();
1276 let mut total = 0u64;
1277 let mut last_end = 100;
1278 while let Some(r) = reader.reserve(7) {
1279 assert!(r.end <= 125, "reservation past the partition cap: {r:?}");
1280 assert_eq!(r.start, last_end, "contiguous reservations");
1281 last_end = r.end;
1282 total += r.end - r.start;
1283 }
1284 assert_eq!(total, 25, "exactly the partition's cardinality");
1285 assert_eq!(last_end, 125);
1286 }
1287
1288 #[test]
1289 fn extending_source_bounded_clamps_oversized_base() {
1290 // A base chunk larger than the partition never reserves
1291 // past it.
1292 let factory =
1293 ExtendingRangeSourceFactory::new("q", 0, 1000, Arc::new(AlwaysExtend)).bounded(30);
1294 let mut reader = factory.create_reader();
1295 let r = reader.reserve(usize::MAX).unwrap();
1296 assert_eq!(r, 0..30);
1297 assert!(reader.reserve(1).is_none());
1298 }
1299
1300 #[test]
1301 fn extending_source_unbounded_keeps_policy_semantics() {
1302 // Without a cap the policy alone decides — three
1303 // extensions of a terminating policy.
1304 struct NTimes(std::sync::atomic::AtomicU64);
1305 impl ExtensionPolicy for NTimes {
1306 fn next_extension(&self, ctx: &ExtensionContext) -> Option<u64> {
1307 if self.0.fetch_add(1, Ordering::Relaxed) < 3 {
1308 Some(ctx.base)
1309 } else {
1310 None
1311 }
1312 }
1313 }
1314 let factory = ExtendingRangeSourceFactory::new(
1315 "q",
1316 0,
1317 10,
1318 Arc::new(NTimes(std::sync::atomic::AtomicU64::new(0))),
1319 );
1320 let mut reader = factory.create_reader();
1321 let mut total = 0u64;
1322 while let Some(r) = reader.reserve(64) {
1323 total += r.end - r.start;
1324 }
1325 assert_eq!(total, 40, "initial 10 + three 10-ordinal extensions");
1326 }
1327
1328 #[test]
1329 fn range_source_yields_ordinals() {
1330 let factory = RangeSourceFactory::new(0, 5);
1331 let mut reader = factory.create_reader();
1332 assert_eq!(reader.extent(), Some(5));
1333
1334 for i in 0..5 {
1335 let item = reader.next().unwrap();
1336 assert_eq!(item.ordinal, i);
1337 assert!(item.fields.is_empty());
1338 }
1339 assert!(reader.next().is_none());
1340 assert_eq!(reader.consumed(), 5);
1341 }
1342
1343 #[test]
1344 fn range_source_chunk() {
1345 let factory = RangeSourceFactory::new(0, 10);
1346 let mut reader = factory.create_reader();
1347
1348 let chunk = reader.next_chunk(3);
1349 assert_eq!(chunk.len(), 3);
1350 assert_eq!(chunk[0].ordinal, 0);
1351 assert_eq!(chunk[2].ordinal, 2);
1352
1353 let chunk = reader.next_chunk(100);
1354 assert_eq!(chunk.len(), 7); // only 7 remaining
1355 assert_eq!(chunk[0].ordinal, 3);
1356 assert_eq!(chunk[6].ordinal, 9);
1357
1358 let chunk = reader.next_chunk(1);
1359 assert!(chunk.is_empty()); // exhausted
1360 }
1361
1362 #[test]
1363 fn range_source_concurrent_readers() {
1364 let factory = RangeSourceFactory::new(0, 100);
1365 let mut r1 = factory.create_reader();
1366 let mut r2 = factory.create_reader();
1367
1368 // Each reader gets unique ordinals from the shared cursor
1369 let a = r1.next().unwrap().ordinal;
1370 let b = r2.next().unwrap().ordinal;
1371 assert_ne!(a, b);
1372
1373 // Drain both readers
1374 let mut total = 2;
1375 while r1.next().is_some() {
1376 total += 1;
1377 }
1378 while r2.next().is_some() {
1379 total += 1;
1380 }
1381 assert_eq!(total, 100);
1382 }
1383
1384 #[test]
1385 fn source_item_field_access() {
1386 let item = SourceItem::with_fields(
1387 42,
1388 vec![
1389 ("name".into(), Value::Str("test".into())),
1390 ("score".into(), Value::F64(0.95)),
1391 ],
1392 );
1393 assert_eq!(item.ordinal, 42);
1394 assert_eq!(item.field("name"), Some(&Value::Str("test".into())));
1395 assert_eq!(item.field("score"), Some(&Value::F64(0.95)));
1396 assert_eq!(item.field("missing"), None);
1397 }
1398
1399 #[test]
1400 fn range_source_named() {
1401 let factory = RangeSourceFactory::named("users", 0, 1000);
1402 assert_eq!(factory.schema().name, "users");
1403 assert_eq!(factory.schema().extent, Some(1000));
1404 }
1405
1406 // ── ExtendingRangeSource ─────────────────────────────────
1407
1408 /// Trivial extension policy for tests: lets the caller
1409 /// decide how many extensions remain. Each call to
1410 /// `next_extension` decrements the count.
1411 struct FixedExtensions {
1412 delta: u64,
1413 remaining: std::sync::atomic::AtomicU64,
1414 }
1415
1416 impl FixedExtensions {
1417 fn new(delta: u64, times: u64) -> Self {
1418 Self {
1419 delta,
1420 remaining: std::sync::atomic::AtomicU64::new(times),
1421 }
1422 }
1423 }
1424
1425 impl ExtensionPolicy for FixedExtensions {
1426 fn next_extension(&self, _ctx: &ExtensionContext) -> Option<u64> {
1427 let prev = self.remaining.fetch_sub(1, Ordering::Relaxed);
1428 if prev == 0 || prev > i64::MAX as u64 {
1429 self.remaining.store(0, Ordering::Relaxed);
1430 None
1431 } else {
1432 Some(self.delta)
1433 }
1434 }
1435 }
1436
1437 #[test]
1438 fn extending_source_consumes_initial_extent_then_extends() {
1439 // Initial extent = 5; policy extends once by 5 more.
1440 // Total expected: 10 ordinals consumed.
1441 let policy = Arc::new(FixedExtensions::new(5, 1));
1442 let factory = ExtendingRangeSourceFactory::new("ext", 0, 5, policy);
1443 let mut reader = factory.create_reader();
1444 let mut got: Vec<u64> = Vec::new();
1445 while let Some(item) = reader.next() {
1446 got.push(item.ordinal);
1447 if got.len() > 50 {
1448 panic!("runaway extension");
1449 }
1450 }
1451 assert_eq!(got, (0..10).collect::<Vec<u64>>());
1452 assert_eq!(reader.consumed(), 10);
1453 }
1454
1455 #[test]
1456 fn extending_source_zero_extensions_behaves_like_fixed_range() {
1457 let policy = Arc::new(FixedExtensions::new(0, 0));
1458 let factory = ExtendingRangeSourceFactory::new("ext", 0, 3, policy);
1459 let mut reader = factory.create_reader();
1460 let mut got: Vec<u64> = Vec::new();
1461 while let Some(item) = reader.next() {
1462 got.push(item.ordinal);
1463 }
1464 assert_eq!(got, vec![0, 1, 2]);
1465 }
1466
1467 #[test]
1468 fn extending_source_global_extent_grows_after_extension() {
1469 // global_extent must reflect the CURRENT end so phase
1470 // status displays show growth honestly.
1471 let policy = Arc::new(FixedExtensions::new(10, 2));
1472 let factory = ExtendingRangeSourceFactory::new("ext", 0, 5, policy);
1473 assert_eq!(factory.global_extent(), Some(5));
1474 let mut reader = factory.create_reader();
1475 // Drain the first 5 to force an extension.
1476 for _ in 0..5 {
1477 reader.next().unwrap();
1478 }
1479 // Trigger the extension by attempting one more pull.
1480 let _ = reader.next().unwrap();
1481 assert_eq!(
1482 factory.global_extent(),
1483 Some(15),
1484 "extent should grow by the extension delta"
1485 );
1486 }
1487
1488 #[test]
1489 fn extending_source_chunked_reservation_caps_at_current_end() {
1490 // A stride request that crosses the current `end` must
1491 // return a SHORT range up to `end`, not an over-claim.
1492 // Otherwise the next reservation would jump past the
1493 // extended range and lose ordinals.
1494 let policy = Arc::new(FixedExtensions::new(5, 1));
1495 let factory = ExtendingRangeSourceFactory::new("ext", 0, 3, policy);
1496 let mut reader = factory.create_reader();
1497 let range = reader.reserve(10).expect("first reserve");
1498 // Initial end was 3; the reservation must cap there.
1499 assert_eq!(range, 0..3);
1500 // Next reserve triggers the extension and consumes the
1501 // remainder.
1502 let range = reader.reserve(10).expect("second reserve");
1503 assert_eq!(range, 3..8);
1504 }
1505
1506 fn ctx_at(elapsed_ms: u64, consumed: u64, base: u64) -> ExtensionContext {
1507 ExtensionContext {
1508 elapsed_ms,
1509 consumed,
1510 base,
1511 }
1512 }
1513
1514 #[test]
1515 fn until_elapsed_policy_bootstrap_falls_back_to_delta_when_no_rate_signal() {
1516 // First end-reach: consumed or elapsed effectively zero —
1517 // no rate to project from. Policy returns `delta` exactly
1518 // so the next call has a measurement to work with.
1519 let policy = UntilElapsedPolicy {
1520 min_ms: 50,
1521 delta: 7,
1522 };
1523 assert_eq!(policy.next_extension(&ctx_at(0, 0, 0)), Some(7));
1524 assert_eq!(policy.next_extension(&ctx_at(49, 0, 0)), Some(7));
1525 assert_eq!(policy.next_extension(&ctx_at(0, 0, 100)), Some(7));
1526 }
1527
1528 #[test]
1529 fn until_elapsed_policy_stops_at_or_past_min_ms() {
1530 let policy = UntilElapsedPolicy {
1531 min_ms: 50,
1532 delta: 7,
1533 };
1534 assert_eq!(policy.next_extension(&ctx_at(50, 100, 100)), None);
1535 assert_eq!(policy.next_extension(&ctx_at(1000, 100, 100)), None);
1536 }
1537
1538 #[test]
1539 fn until_elapsed_policy_projects_remaining_cycles_with_under_bias() {
1540 // base=100, consumed=100 (one pass), elapsed=200ms,
1541 // min_ms=1000 → remaining=800ms. Rate = 100/200 = 0.5
1542 // cycles/ms; project 0.5 * 800 = 400 cycles. Under-bias
1543 // 5% → 380. Round to multiples of base (100) → 3 repeats
1544 // → 300 cycles.
1545 let policy = UntilElapsedPolicy {
1546 min_ms: 1000,
1547 delta: 100,
1548 };
1549 assert_eq!(
1550 policy.next_extension(&ctx_at(200, 100, 100)),
1551 Some(300),
1552 "expected 3-pass batch (380 under-biased, floored to base multiples)",
1553 );
1554 }
1555
1556 #[test]
1557 fn until_elapsed_policy_returns_none_when_remaining_is_under_one_pass() {
1558 // base=100, consumed=10000 over 990ms, min_ms=1000 →
1559 // remaining=10ms. Project 10000/990*10 ≈ 101 cycles.
1560 // Under-bias → 95. Rounded to base multiples → 0 repeats.
1561 // Policy terminates rather than under-shooting the budget
1562 // with a wasted partial pass.
1563 let policy = UntilElapsedPolicy {
1564 min_ms: 1000,
1565 delta: 100,
1566 };
1567 assert_eq!(policy.next_extension(&ctx_at(990, 10000, 100)), None);
1568 }
1569
1570 #[test]
1571 fn until_elapsed_policy_converges_geometrically() {
1572 // Sanity: simulate the extension loop. Each iteration
1573 // commits ~95% of the remaining time budget; the
1574 // residual is bounded below by the base-pass rounding,
1575 // so convergence is one-base-coarse rather than
1576 // arbitrarily tight.
1577 let policy = UntilElapsedPolicy {
1578 min_ms: 1000,
1579 delta: 10,
1580 };
1581 // Pretend the first pass took 10ms (rate = 1 cycle/ms).
1582 let mut elapsed = 10u64;
1583 let mut consumed = 10u64;
1584 let mut iters = 0;
1585 while let Some(delta) = policy.next_extension(&ctx_at(elapsed, consumed, 10)) {
1586 iters += 1;
1587 assert!(iters < 10, "geometric series should converge fast");
1588 consumed += delta;
1589 // Same rate: 1 cycle/ms.
1590 elapsed += delta;
1591 }
1592 assert!(elapsed <= 1000, "must under-shoot, got elapsed={elapsed}");
1593 // Residual ≤ ~5% (under-bias) + 1 base pass (rounding) ≈ 6% of target.
1594 assert!(
1595 elapsed >= 940,
1596 "must come within ~6% of target, got {elapsed}"
1597 );
1598 }
1599
1600 #[test]
1601 fn until_passes_policy_counts_in_base_multiples() {
1602 let policy = UntilPassesPolicy {
1603 min_passes: 3,
1604 delta: 100,
1605 };
1606 // 0 passes done — extend.
1607 assert_eq!(policy.next_extension(&ctx_at(0, 0, 100)), Some(100));
1608 // 2 passes done (200 consumed @ base=100) — extend.
1609 assert_eq!(policy.next_extension(&ctx_at(0, 200, 100)), Some(100));
1610 // 3 passes done — stop.
1611 assert_eq!(policy.next_extension(&ctx_at(0, 300, 100)), None);
1612 // 4 passes done — stop.
1613 assert_eq!(policy.next_extension(&ctx_at(0, 400, 100)), None);
1614 }
1615
1616 #[test]
1617 fn until_count_policy_uses_raw_consumed() {
1618 let policy = UntilCountPolicy {
1619 min_count: 250,
1620 delta: 50,
1621 };
1622 assert_eq!(policy.next_extension(&ctx_at(0, 0, 100)), Some(50));
1623 assert_eq!(policy.next_extension(&ctx_at(0, 249, 100)), Some(50));
1624 assert_eq!(policy.next_extension(&ctx_at(0, 250, 100)), None);
1625 }
1626
1627 #[test]
1628 fn and_policy_stops_when_any_child_stops() {
1629 // time<5000 AND passes<3.
1630 let time = Arc::new(UntilElapsedPolicy {
1631 min_ms: 5000,
1632 delta: 10,
1633 });
1634 let passes = Arc::new(UntilPassesPolicy {
1635 min_passes: 3,
1636 delta: 20,
1637 });
1638 let and = AndPolicy {
1639 policies: vec![time, passes],
1640 };
1641 // Both still want to continue: delta = min(10, 20) = 10.
1642 assert_eq!(and.next_extension(&ctx_at(0, 0, 100)), Some(10));
1643 // Time done — stop.
1644 assert_eq!(and.next_extension(&ctx_at(5000, 0, 100)), None);
1645 // Passes done — stop.
1646 assert_eq!(and.next_extension(&ctx_at(0, 300, 100)), None);
1647 }
1648
1649 #[test]
1650 fn or_policy_continues_if_any_child_continues() {
1651 let time = Arc::new(UntilElapsedPolicy {
1652 min_ms: 5000,
1653 delta: 10,
1654 });
1655 let passes = Arc::new(UntilPassesPolicy {
1656 min_passes: 3,
1657 delta: 20,
1658 });
1659 let or = OrPolicy {
1660 policies: vec![time, passes],
1661 };
1662 // Both want to continue → delta = max(10, 20) = 20.
1663 assert_eq!(or.next_extension(&ctx_at(0, 0, 100)), Some(20));
1664 // Time done, passes still wants → delta = 20.
1665 assert_eq!(or.next_extension(&ctx_at(5000, 0, 100)), Some(20));
1666 // Passes done, time still wants → delta = 10.
1667 assert_eq!(or.next_extension(&ctx_at(0, 300, 100)), Some(10));
1668 // Both done → stop.
1669 assert_eq!(or.next_extension(&ctx_at(5000, 300, 100)), None);
1670 }
1671
1672 #[test]
1673 fn time_elapsed_policy_compat_alias_still_works() {
1674 let p = TimeElapsedPolicy::new(7, 1);
1675 std::thread::sleep(std::time::Duration::from_millis(20));
1676 assert_eq!(p.next_extension(&ctx_at(20, 0, 0)), None);
1677 }
1678}