@@ -84,9 +84,17 @@ use super::mode::BASIN_NONE;
8484/// to decide between Delta (fits in i8) and Escape (overflows).
8585/// `i32` width avoids overflow when the caller computes
8686/// `cell_value - basin_value` for two u8 inputs.
87- /// - `neighbours`: NESW (in [`MergeDir`] discriminant order) optional
88- /// neighbour leaves. `None` for boundary cells; the Merge candidate
89- /// scan skips `None` entries.
87+ /// - `neighbours`: NEWS (in [`MergeDir`] discriminant order:
88+ /// `North=0, East=1, West=2, South=3`) optional neighbour leaves.
89+ /// `None` for boundary cells; the Merge candidate scan skips `None`
90+ /// entries.
91+ ///
92+ /// ```text
93+ /// slot 0 → MergeDir::North (discr 0)
94+ /// slot 1 → MergeDir::East (discr 1)
95+ /// slot 2 → MergeDir::West (discr 2)
96+ /// slot 3 → MergeDir::South (discr 3)
97+ /// ```
9098///
9199/// ```
92100/// use ndarray::hpc::codec::{IntraContext, LeafCu};
@@ -105,41 +113,27 @@ pub struct IntraContext<'a> {
105113 /// Signed delta from basin → cell, in the basin's u8 quantisation
106114 /// space.
107115 pub delta_i32 : i32 ,
108- /// NESW neighbour leaves, indexed by [`MergeDir`] discriminant.
116+ /// NEWS neighbour leaves, indexed by [`MergeDir`] discriminant
117+ /// (`North=0, East=1, West=2, South=3`).
109118 pub neighbours : [ Option < & ' a LeafCu > ; 4 ] ,
110119}
111120
112121/// Configuration for the intra-prediction decision.
113122///
114- /// Today a single field; the field exists so the API can grow
115- /// (Merge tolerance, RDO knobs in A6) without a signature break.
123+ /// Reserved for future expansion (Merge tolerance, RDO knobs in A6).
124+ /// Empty today; constructed via [`Default`] so additions don't break
125+ /// callers.
116126///
117127/// ```
118128/// use ndarray::hpc::codec::IntraConfig;
119- /// let default_cfg = IntraConfig::default();
120- /// assert!(default_cfg.escape_next_idx.is_none());
121- /// let allocated = IntraConfig { escape_next_idx: Some(42) };
122- /// assert_eq!(allocated.escape_next_idx, Some(42));
129+ /// let cfg = IntraConfig::default();
130+ /// // No tunables yet — call sites stay future-compatible.
131+ /// let _ = cfg;
123132/// ```
124- #[ derive( Debug , Clone , Copy ) ]
133+ #[ derive( Debug , Clone , Copy , Default ) ]
125134pub struct IntraConfig {
126- /// Future allocator for the encoder's escape vector — returns the
127- /// next index to write. `None` disables Escape mode (the encoder
128- /// will fall back to Delta-with-truncation, which **loses
129- /// precision** but never panics; callers wanting lossless coding
130- /// must provide a real allocator).
131- ///
132- /// Stateless API today: encoder calls `escape_next_idx` once per
133- /// Escape decision. The caller is responsible for actually
134- /// appending the u64 cell value into the escape vector at the
135- /// returned index — this kernel doesn't see the cell value.
136- pub escape_next_idx : Option < u32 > ,
137- }
138-
139- impl Default for IntraConfig {
140- fn default ( ) -> Self {
141- Self { escape_next_idx : None }
142- }
135+ // Reserved. Future fields land here without breaking the signature.
136+ _reserved : ( ) ,
143137}
144138
145139// ════════════════════════════════════════════════════════════════════
@@ -152,6 +146,14 @@ impl Default for IntraConfig {
152146/// See the module docs for the decision tree (Skip → Merge → Delta →
153147/// Escape) and the rationale (monotone wire cost).
154148///
149+ /// `escape_next` is a write-cursor into the caller's per-frame escape
150+ /// vector. When the decision falls through to Escape, the kernel reads
151+ /// the cursor, emits a leaf referencing that idx, and post-increments
152+ /// the cursor so subsequent cells in the same batch get fresh,
153+ /// non-colliding idxs. Pass `None` to disable lossless Escape — the
154+ /// kernel then clamps `δ` to i8 range and emits a `Delta` leaf whose
155+ /// reconstruction is **not bit-exact** (caller must accept the loss).
156+ ///
155157/// # Examples
156158///
157159/// Skip when the cell is exactly the basin:
@@ -164,7 +166,7 @@ impl Default for IntraConfig {
164166/// delta_i32: 0,
165167/// neighbours: [None; 4],
166168/// };
167- /// let leaf = predict_intra(&ctx, &IntraConfig::default());
169+ /// let leaf = predict_intra(&ctx, &IntraConfig::default(), None );
168170/// assert_eq!(leaf.mode, CellMode::Skip);
169171/// assert_eq!(leaf.basin_idx, 42);
170172/// ```
@@ -179,11 +181,26 @@ impl Default for IntraConfig {
179181/// delta_i32: 17,
180182/// neighbours: [None; 4],
181183/// };
182- /// let leaf = predict_intra(&ctx, &IntraConfig::default());
184+ /// let leaf = predict_intra(&ctx, &IntraConfig::default(), None );
183185/// assert_eq!(leaf.mode, CellMode::Delta);
184186/// assert_eq!(leaf.delta, Some(17));
185187/// ```
186- pub fn predict_intra ( ctx : & IntraContext , cfg : & IntraConfig ) -> LeafCu {
188+ ///
189+ /// Escape with an allocator — repeated calls bump the cursor:
190+ ///
191+ /// ```
192+ /// use ndarray::hpc::codec::predict::{predict_intra, IntraContext, IntraConfig};
193+ /// use ndarray::hpc::codec::CellMode;
194+ /// let mut next = 7u32;
195+ /// let ctx = IntraContext { basin_idx: 1, delta_i32: 1000, neighbours: [None; 4] };
196+ /// let a = predict_intra(&ctx, &IntraConfig::default(), Some(&mut next));
197+ /// let b = predict_intra(&ctx, &IntraConfig::default(), Some(&mut next));
198+ /// assert_eq!(a.escape_idx, Some(7));
199+ /// assert_eq!(b.escape_idx, Some(8));
200+ /// assert_eq!(next, 9);
201+ /// assert_eq!(a.mode, CellMode::Escape);
202+ /// ```
203+ pub fn predict_intra ( ctx : & IntraContext , _cfg : & IntraConfig , escape_next : Option < & mut u32 > ) -> LeafCu {
187204 // ── 1. Skip ──────────────────────────────────────────────────────
188205 if ctx. delta_i32 == 0 {
189206 return LeafCu :: skip ( ctx. basin_idx ) ;
@@ -209,7 +226,8 @@ pub fn predict_intra(ctx: &IntraContext, cfg: &IntraConfig) -> LeafCu {
209226 // wrapping cast; matches the A2 pack format where Delta
210227 // stores a raw u8 byte without a sign bit)
211228 //
212- // We scan NESW in discriminant order and pick the first match.
229+ // We scan NEWS in discriminant order (N=0, E=1, W=2, S=3) and
230+ // pick the first match.
213231 // Multiple matches all collapse to the same coded leaf, so the
214232 // first-hit policy is order-deterministic without affecting
215233 // bitstream length.
@@ -242,16 +260,23 @@ pub fn predict_intra(ctx: &IntraContext, cfg: &IntraConfig) -> LeafCu {
242260
243261 // ── 4. Escape ────────────────────────────────────────────────────
244262 //
245- // |δ| doesn't fit in i8. Caller must own the per-frame escape
246- // vector and provide the next-write index; we return a leaf that
247- // references it. If the caller didn't provide an allocator, we
248- // fall back to a saturated Delta (lossy but never panicking) so
249- // a misconfigured encoder still produces a valid bytestream.
250- match cfg. escape_next_idx {
251- Some ( idx) => LeafCu :: escape ( ctx. basin_idx , idx) ,
263+ // |δ| doesn't fit in i8. The cursor `escape_next` is a write-pointer
264+ // into the caller's per-frame escape vector; we read it, emit a
265+ // leaf referencing that idx, and post-increment so subsequent
266+ // overflow cells in the batch don't collide on the same vector
267+ // slot. If the caller didn't provide an allocator, we fall back to
268+ // a saturated Delta (lossy: reconstruction is NOT bit-exact, but
269+ // never panicking) so a misconfigured encoder still produces a
270+ // valid bytestream. The lossy leaf's `mode` is `CellMode::Delta`
271+ // even though its semantic value overflowed i8 — by contract the
272+ // caller has acknowledged the precision loss.
273+ match escape_next {
274+ Some ( next) => {
275+ let idx = * next;
276+ * next = next. wrapping_add ( 1 ) ;
277+ LeafCu :: escape ( ctx. basin_idx , idx)
278+ }
252279 None => {
253- // Lossy fallback: clamp to i8 range. Caller is responsible
254- // for noticing that the reconstruction won't be bit-exact.
255280 let clamped = ctx. delta_i32 . clamp ( -128 , 127 ) as u8 ;
256281 LeafCu :: delta ( ctx. basin_idx , clamped)
257282 }
@@ -301,7 +326,7 @@ mod tests {
301326
302327 #[ test]
303328 fn skip_when_delta_is_zero ( ) {
304- let leaf = predict_intra ( & ctx_with_neighbours ( 100 , 0 , [ None ; 4 ] ) , & IntraConfig :: default ( ) ) ;
329+ let leaf = predict_intra ( & ctx_with_neighbours ( 100 , 0 , [ None ; 4 ] ) , & IntraConfig :: default ( ) , None ) ;
305330 assert_eq ! ( leaf, LeafCu :: skip( 100 ) ) ;
306331 }
307332
@@ -310,14 +335,14 @@ mod tests {
310335 // δ=0 trumps everything else, even a perfect Merge candidate.
311336 let nb = LeafCu :: delta ( 100 , 0 ) ;
312337 let neighbours = [ Some ( & nb) , None , None , None ] ;
313- let leaf = predict_intra ( & ctx_with_neighbours ( 100 , 0 , neighbours) , & IntraConfig :: default ( ) ) ;
338+ let leaf = predict_intra ( & ctx_with_neighbours ( 100 , 0 , neighbours) , & IntraConfig :: default ( ) , None ) ;
314339 assert_eq ! ( leaf. mode, CellMode :: Skip ) ;
315340 }
316341
317342 #[ test]
318343 fn delta_in_i8_range ( ) {
319344 for d in [ -128i32 , -1 , 1 , 127 ] {
320- let leaf = predict_intra ( & ctx_with_neighbours ( 100 , d, [ None ; 4 ] ) , & IntraConfig :: default ( ) ) ;
345+ let leaf = predict_intra ( & ctx_with_neighbours ( 100 , d, [ None ; 4 ] ) , & IntraConfig :: default ( ) , None ) ;
321346 assert_eq ! ( leaf. mode, CellMode :: Delta ) ;
322347 assert_eq ! ( leaf. delta, Some ( d as u8 ) ) ;
323348 }
@@ -328,7 +353,7 @@ mod tests {
328353 // Northern neighbour: Delta-mode, same basin, same δ as us.
329354 let nb_north = LeafCu :: delta ( 100 , 17 ) ;
330355 let neighbours = [ Some ( & nb_north) , None , None , None ] ;
331- let leaf = predict_intra ( & ctx_with_neighbours ( 100 , 17 , neighbours) , & IntraConfig :: default ( ) ) ;
356+ let leaf = predict_intra ( & ctx_with_neighbours ( 100 , 17 , neighbours) , & IntraConfig :: default ( ) , None ) ;
332357 assert_eq ! ( leaf. mode, CellMode :: Merge ) ;
333358 assert_eq ! ( leaf. merge_dir, Some ( MergeDir :: North ) ) ;
334359 assert_eq ! ( leaf. basin_idx, 100 ) ;
@@ -340,7 +365,7 @@ mod tests {
340365 // reference frame). Falls through to Delta.
341366 let nb_north = LeafCu :: delta ( 99 , 17 ) ;
342367 let neighbours = [ Some ( & nb_north) , None , None , None ] ;
343- let leaf = predict_intra ( & ctx_with_neighbours ( 100 , 17 , neighbours) , & IntraConfig :: default ( ) ) ;
368+ let leaf = predict_intra ( & ctx_with_neighbours ( 100 , 17 , neighbours) , & IntraConfig :: default ( ) , None ) ;
344369 assert_eq ! ( leaf. mode, CellMode :: Delta ) ;
345370 }
346371
@@ -351,55 +376,86 @@ mod tests {
351376 let nb_merge = LeafCu :: merge ( 100 , MergeDir :: North ) ;
352377 let nb_esc = LeafCu :: escape ( 100 , 0 ) ;
353378 let neighbours = [ Some ( & nb_skip) , Some ( & nb_merge) , None , Some ( & nb_esc) ] ;
354- let leaf = predict_intra ( & ctx_with_neighbours ( 100 , 17 , neighbours) , & IntraConfig :: default ( ) ) ;
379+ let leaf = predict_intra ( & ctx_with_neighbours ( 100 , 17 , neighbours) , & IntraConfig :: default ( ) , None ) ;
355380 assert_eq ! ( leaf. mode, CellMode :: Delta ) ;
356381 }
357382
358383 #[ test]
359- fn merge_picks_first_hit_in_nesw_order ( ) {
384+ fn merge_picks_first_hit_in_news_order ( ) {
360385 // Both N and E qualify; encoder must pick N (lower index).
361386 let nb_match = LeafCu :: delta ( 100 , 17 ) ;
362387 let neighbours = [ Some ( & nb_match) , Some ( & nb_match) , None , None ] ;
363- let leaf = predict_intra ( & ctx_with_neighbours ( 100 , 17 , neighbours) , & IntraConfig :: default ( ) ) ;
388+ let leaf = predict_intra ( & ctx_with_neighbours ( 100 , 17 , neighbours) , & IntraConfig :: default ( ) , None ) ;
364389 assert_eq ! ( leaf. merge_dir, Some ( MergeDir :: North ) ) ;
365390 }
366391
392+ #[ test]
393+ fn merge_slot_2_maps_to_west_and_slot_3_to_south ( ) {
394+ // Slot-3 South coverage gap noted in review. Verify the
395+ // discriminant order (N=0, E=1, W=2, S=3) is reflected at
396+ // the merge_dir output, not just NEWS-by-convention.
397+ let nb = LeafCu :: delta ( 100 , 17 ) ;
398+
399+ let only_west = [ None , None , Some ( & nb) , None ] ;
400+ let leaf_w = predict_intra ( & ctx_with_neighbours ( 100 , 17 , only_west) , & IntraConfig :: default ( ) , None ) ;
401+ assert_eq ! ( leaf_w. merge_dir, Some ( MergeDir :: West ) ) ;
402+
403+ let only_south = [ None , None , None , Some ( & nb) ] ;
404+ let leaf_s = predict_intra ( & ctx_with_neighbours ( 100 , 17 , only_south) , & IntraConfig :: default ( ) , None ) ;
405+ assert_eq ! ( leaf_s. merge_dir, Some ( MergeDir :: South ) ) ;
406+ }
407+
367408 #[ test]
368409 fn merge_negative_delta_via_wrapping_cast ( ) {
369410 // δ = -17 packs to 0xEF (= 239 as u8). Neighbour stored as
370411 // u8 = 0xEF MUST match — the cast must be wrapping, not
371412 // saturating.
372413 let nb_match = LeafCu :: delta ( 100 , ( -17_i32 ) as u8 ) ;
373414 let neighbours = [ None , Some ( & nb_match) , None , None ] ;
374- let leaf = predict_intra ( & ctx_with_neighbours ( 100 , -17 , neighbours) , & IntraConfig :: default ( ) ) ;
415+ let leaf = predict_intra ( & ctx_with_neighbours ( 100 , -17 , neighbours) , & IntraConfig :: default ( ) , None ) ;
375416 assert_eq ! ( leaf. mode, CellMode :: Merge ) ;
376417 assert_eq ! ( leaf. merge_dir, Some ( MergeDir :: East ) ) ;
377418 }
378419
379420 #[ test]
380421 fn escape_when_delta_overflows_i8_and_allocator_present ( ) {
381- let cfg = IntraConfig {
382- escape_next_idx : Some ( 42 ) ,
383- } ;
384- let leaf = predict_intra ( & ctx_with_neighbours ( 100 , 1000 , [ None ; 4 ] ) , & cfg) ;
422+ let mut next = 42u32 ;
423+ let leaf = predict_intra ( & ctx_with_neighbours ( 100 , 1000 , [ None ; 4 ] ) , & IntraConfig :: default ( ) , Some ( & mut next) ) ;
385424 assert_eq ! ( leaf. mode, CellMode :: Escape ) ;
386425 assert_eq ! ( leaf. escape_idx, Some ( 42 ) ) ;
387426 assert_eq ! ( leaf. basin_idx, 100 ) ;
427+ // Cursor advanced so the next Escape gets a fresh idx.
428+ assert_eq ! ( next, 43 ) ;
429+ }
430+
431+ #[ test]
432+ fn escape_allocator_advances_across_batched_calls ( ) {
433+ // Regression: two consecutive Escape decisions must not
434+ // collide on the same vector slot. With a `&mut u32` cursor
435+ // the kernel post-increments, so cell A sees idx N and
436+ // cell B sees idx N+1.
437+ let mut next = 5u32 ;
438+ let a = predict_intra ( & ctx_with_neighbours ( 7 , 999 , [ None ; 4 ] ) , & IntraConfig :: default ( ) , Some ( & mut next) ) ;
439+ let b = predict_intra ( & ctx_with_neighbours ( 7 , -999 , [ None ; 4 ] ) , & IntraConfig :: default ( ) , Some ( & mut next) ) ;
440+ assert_eq ! ( a. escape_idx, Some ( 5 ) ) ;
441+ assert_eq ! ( b. escape_idx, Some ( 6 ) ) ;
442+ assert_eq ! ( next, 7 ) ;
443+ assert_ne ! ( a. escape_idx, b. escape_idx) ;
388444 }
389445
390446 #[ test]
391447 fn escape_lossy_fallback_when_no_allocator ( ) {
392448 // Without an escape_next_idx, the encoder clamps to i8 range.
393449 // The result is a valid LeafCu but the reconstruction won't
394450 // be bit-exact.
395- let leaf = predict_intra ( & ctx_with_neighbours ( 100 , 1000 , [ None ; 4 ] ) , & IntraConfig :: default ( ) ) ;
451+ let leaf = predict_intra ( & ctx_with_neighbours ( 100 , 1000 , [ None ; 4 ] ) , & IntraConfig :: default ( ) , None ) ;
396452 assert_eq ! ( leaf. mode, CellMode :: Delta ) ;
397453 assert_eq ! ( leaf. delta, Some ( 127 ) ) ;
398454 }
399455
400456 #[ test]
401457 fn escape_lossy_fallback_negative_overflow ( ) {
402- let leaf = predict_intra ( & ctx_with_neighbours ( 100 , -1000 , [ None ; 4 ] ) , & IntraConfig :: default ( ) ) ;
458+ let leaf = predict_intra ( & ctx_with_neighbours ( 100 , -1000 , [ None ; 4 ] ) , & IntraConfig :: default ( ) , None ) ;
403459 assert_eq ! ( leaf. mode, CellMode :: Delta ) ;
404460 assert_eq ! ( leaf. delta, Some ( ( -128_i32 ) as u8 ) ) ;
405461 }
@@ -412,7 +468,7 @@ mod tests {
412468 use super :: super :: mode:: { pack_leaf, unpack_leaf} ;
413469 let nb = LeafCu :: delta ( 100 , 17 ) ;
414470 let neighbours = [ None , Some ( & nb) , None , None ] ;
415- let leaf = predict_intra ( & ctx_with_neighbours ( 100 , 17 , neighbours) , & IntraConfig :: default ( ) ) ;
471+ let leaf = predict_intra ( & ctx_with_neighbours ( 100 , 17 , neighbours) , & IntraConfig :: default ( ) , None ) ;
416472 assert_eq ! ( leaf. mode, CellMode :: Merge ) ;
417473
418474 let mut buf = [ 0u8 ; 6 ] ;
@@ -438,7 +494,7 @@ mod tests {
438494 // clamp fallback because no allocator is wired).
439495 let nb_alias = LeafCu :: delta ( 100 , 0xC8 ) ;
440496 let neighbours = [ Some ( & nb_alias) , None , None , None ] ;
441- let leaf = predict_intra ( & ctx_with_neighbours ( 100 , 200 , neighbours) , & IntraConfig :: default ( ) ) ;
497+ let leaf = predict_intra ( & ctx_with_neighbours ( 100 , 200 , neighbours) , & IntraConfig :: default ( ) , None ) ;
442498 assert_ne ! ( leaf. mode, CellMode :: Merge , "overflow δ must not Merge" ) ;
443499 // With no allocator the encoder clamps to +127 (lossy Delta).
444500 assert_eq ! ( leaf. mode, CellMode :: Delta ) ;
@@ -449,12 +505,11 @@ mod tests {
449505 fn overflow_delta_with_allocator_takes_escape ( ) {
450506 let nb_alias = LeafCu :: delta ( 100 , 0xC8 ) ;
451507 let neighbours = [ Some ( & nb_alias) , None , None , None ] ;
452- let cfg = IntraConfig {
453- escape_next_idx : Some ( 7 ) ,
454- } ;
455- let leaf = predict_intra ( & ctx_with_neighbours ( 100 , 200 , neighbours) , & cfg) ;
508+ let mut next = 7u32 ;
509+ let leaf = predict_intra ( & ctx_with_neighbours ( 100 , 200 , neighbours) , & IntraConfig :: default ( ) , Some ( & mut next) ) ;
456510 assert_eq ! ( leaf. mode, CellMode :: Escape ) ;
457511 assert_eq ! ( leaf. escape_idx, Some ( 7 ) ) ;
512+ assert_eq ! ( next, 8 ) ;
458513 }
459514
460515 #[ test]
0 commit comments