From 67073728dae7c1c4866542f9e787621ec6697044 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 10:47:18 +0000 Subject: [PATCH] test(layered-path): pin k-prefix stability for solve_k_best MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A caller that shows three alternatives and then asks for five must not watch the first three renumber themselves. The accepted engine already satisfies this — neither the enumeration order nor the greedy diversity filter reads `k` — so this is a **characterization test**, not a red→green fix: no production code changes, and the acceptance given at `7d0c0cb` is not reopened. The property was specified and left unasserted. The deterministic-search proposal (#176, still a draft) asks for it in as many words — "requesting more results never changes the prefix already returned" — and "true by construction" is exactly the kind of claim that quietly stops holding one refactor later without anything failing. Compared per rank, not merely per path list: ordinals, `total_cost` bits, and the full `Debug` rendering, which carries every step and edge with its axes, rationale entries, weights and provenance. `f64` renders in shortest round-trip form, so a cost differing in one bit renders differently — "the same path" cannot quietly become "the same indices, differently computed". Swept over all 120 width vectors for 1–4 layers over widths 1–3, every `min_distance`, and every `k` below the widest request, with an anti-vacuity floor on the number of ranks actually compared. `exhausted` is deliberately excluded from the compared prefix: it describes the *search*, not the paths, and a request for fewer alternatives can legitimately stop before the space runs out. Verification: `cargo test --workspace` 1454 passed / 0 failed (1453 before, +1); clippy `--workspace --all-targets -D warnings` clean; `cargo fmt --all --check` clean. The diff touches one test file and no production code. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TvcPGrQsgY4HqzmdDcRqvu --- core/tests/layered_k_best.rs | 106 +++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/core/tests/layered_k_best.rs b/core/tests/layered_k_best.rs index d0082f4..f579fe8 100644 --- a/core/tests/layered_k_best.rs +++ b/core/tests/layered_k_best.rs @@ -363,6 +363,112 @@ fn the_engine_agrees_with_brute_force_on_every_shape_ragged_ones_included() { ); } +// ── the prefix already returned never changes ──────────────────────────────── + +/// Everything a caller can observe about one alternative. +/// +/// The `Debug` rendering carries the whole value — every step and edge with its +/// axes, rationale entries, weights and provenance — and `f64` renders to its +/// shortest round-trip form, so two costs differing in a single bit render +/// differently. The ordinals and the raw cost bits ride along separately so a +/// failure says *which* part moved instead of printing two walls of text. +fn observable(path: &PathSolution) -> (Vec, u64, String) { + ( + path.ordinals(), + path.total_cost.to_bits(), + format!("{path:?}"), + ) +} + +#[test] +fn asking_for_more_alternatives_never_changes_the_ones_already_returned() { + // A caller that shows three alternatives and then asks for five must not + // watch the first three renumber themselves. The property holds by + // construction — neither the enumeration order nor the greedy diversity + // filter reads `k` — but "by construction" is exactly the kind of claim + // that stops being true one refactor later without anything failing. + // + // This is a characterization test: the accepted engine already satisfies + // it, and nothing in production changes. It pins a contract that was + // specified (the deterministic-search proposal asks for it in as many + // words) and left unasserted. + // + // `exhausted` is deliberately not part of the compared prefix: it describes + // the *search*, not the paths, and a request for fewer alternatives can + // legitimately stop before the space runs out. + const LOCALS: [&[f64]; 4] = [ + &[1.0, 2.0, 4.0], + &[3.0, 1.0, 2.0], + &[2.0, 5.0, 1.0], + &[1.0, 3.0, 2.0], + ]; + const TRANSITIONS: [&[&[f64]]; 3] = [ + &[&[1.0, 2.0, 3.0], &[2.0, 1.0, 4.0], &[3.0, 3.0, 1.0]], + &[&[2.0, 1.0, 3.0], &[1.0, 4.0, 2.0], &[3.0, 2.0, 1.0]], + &[&[1.0, 3.0, 2.0], &[4.0, 1.0, 3.0], &[2.0, 2.0, 1.0]], + ]; + const WIDEST: usize = 5; + + let mut compared = 0_usize; + for layers in 1..=4_usize { + for widths in width_vectors(layers, 3) { + let locals_raw: Vec<&[f64]> = (0..layers).map(|l| &LOCALS[l][..widths[l]]).collect(); + let owned_rows: Vec> = (0..layers.saturating_sub(1)) + .map(|l| { + (0..widths[l]) + .map(|r| &TRANSITIONS[l][r][..widths[l + 1]]) + .collect() + }) + .collect(); + let transitions_raw: Vec<&[&[f64]]> = owned_rows.iter().map(Vec::as_slice).collect(); + + let locals = locals_of(&locals_raw); + let transitions = transitions_of(&transitions_raw); + let p = policy(); + let problem = LayeredProblem { + locals: &locals, + transitions: &transitions, + policy: &p, + }; + + for min_distance in 1..=layers { + let widest = solve_k_best(&problem, request(WIDEST, min_distance)) + .expect("a well-formed problem solves"); + for k in 1..WIDEST { + let narrower = solve_k_best(&problem, request(k, min_distance)) + .expect("a well-formed problem solves"); + + assert!( + narrower.paths.len() <= widest.paths.len(), + "a smaller k cannot yield more alternatives" + ); + assert_eq!( + narrower.paths.len(), + widest.paths.len().min(k), + "a smaller k yields exactly its share, widths {widths:?}, \ + distance {min_distance}" + ); + for (rank, (narrow, wide)) in + narrower.paths.iter().zip(widest.paths.iter()).enumerate() + { + assert_eq!( + observable(narrow), + observable(wide), + "rank {rank} moved between k={k} and k={WIDEST}, \ + widths {widths:?}, distance {min_distance}" + ); + compared += 1; + } + } + } + } + } + assert!( + compared >= 1000, + "the sweep actually compared paths: {compared}" + ); +} + // ── the diversity rule is explicit and enforced ────────────────────────────── #[test]