From cb8605491bde20feb26515b569f27fc1b84e57fd Mon Sep 17 00:00:00 2001 From: CXPhoenix <0826@fhsh.tp.edu.tw> Date: Sun, 26 Jul 2026 15:28:53 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20feat:=20=E6=B8=AC=E8=B3=87?= =?UTF-8?q?=E7=94=A2=E7=94=9F=E5=99=A8=E6=96=B0=E5=A2=9E=20distinct=20?= =?UTF-8?q?=E8=88=87=20prefix=5Fcount=20=E5=8F=83=E6=95=B8=E6=AC=84?= =?UTF-8?q?=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📋 變更細節分析 - ParamSpec 全部變體新增 `distinct` / `prefix_count` 頂層欄位(預設 false,未宣告時行為完全不變):`distinct: true` 保證同行值兩兩相異(int / enum 支援,字串型別與 faker 於建構期報錯);`prefix_count: true` 以 join 語意在行首輸出實際個數 n(APCS 格式,n = 0 時輸出恰為 `0`) - 建構期驗證擴充:值域大小以 i128 寬運算計算並驗證 ≥ count.max(全 i64 範圍不溢位)、enum values 去重後驗證,失敗即回傳描述性錯誤,不默默生出重複值 - 不放回抽樣採門檻式混合策略:值域 ≤ 4 × count.max 走展開 + partial Fisher–Yates(緊繃情境輸出隨機排列);值域大走 rejection sampling + HashSet(單次碰撞機率 < 1/4) - conformance 測試改為「JSON 資料檔 + Rust harness」形態:26 個 fixtures 對應需求追溯矩陣 M1–M22 與硬化案例,params 與宣告式期望為語言中立資料,為未來多實作共用預留 - 品質檢查(固定 seed):Q1 輸出順序 smoke test(兩組參數)、Q2 選值均勻性卡方檢定(df=19、臨界值 43.82)、Q3 效能參考測試(release 組態、單行 < 100 ms) - Round 1 audit + 三方 adversarial review 硬化:ParamSpec 與 CountSpec 加 `deny_unknown_fields`(拼錯欄位名建構期報錯,不再靜默停用保證);`pub mod` 收回改開窄 API `generate(params_json, count, rng) -> Result`(消除繞過驗證的 panic 路徑);`generate_challenge` 的 count 參數加 10^4 上限;rng 內部 positional tuple 改具名 CommonFields 存取器 - 移除 rng.rs 兩處 `debug_assert!`(release 被 strip 的假安全),驗證全數由 parse 層以 Result 承擔;CI 兩個 Rust 測試步驟加 `--all-features` 並新增 `cargo test --release` 步驟 - 新增 crate README(欄位規格、逐型別支援表、全部邊界行為、separator 責任註記)與 changeset(minor) - Spectra change artifacts:proposal / design(含 Round 1 硬化決策紀錄)/ spec delta(含 Unknown parameter fields are rejected requirement)/ tasks 17 項全數完成 ## 🔧 技術影響 - `@cxphoenix/vp-wasm-coding` 將出 minor 版:params schema 新增兩個向後相容欄位;帶未知鍵的 params 從靜默忽略改為建構期報錯(M22 窄讀下的有意收緊,conforming params 不受影響) - Rust crate 公開 API 新增 native 入口 `generate`;`generate_challenge` WASM 簽名不變 - `deny_unknown_fields` 與 serde `flatten` 互斥——未來 R3 若需 flatten 須另行提供此保證(已記錄於 design.md) --- .changeset/distinct-prefix-count.md | 8 + .github/workflows/ci.yml | 11 +- crates/random-input-generator/README.md | 127 ++++++ crates/random-input-generator/src/lib.rs | 58 ++- crates/random-input-generator/src/parser.rs | 276 +++++++++++- crates/random-input-generator/src/rng.rs | 394 ++++++++++++++++-- .../tests/conformance.rs | 205 +++++++++ .../fixtures/m01_distinct_int_basic.json | 29 ++ .../tests/fixtures/m02_prefix_int_format.json | 29 ++ .../fixtures/m03_prefix_string_format.json | 24 ++ .../fixtures/m04_prefix_enum_format.json | 32 ++ .../fixtures/m05_prefix_custom_separator.json | 30 ++ .../m06_distinct_prefix_combined.json | 31 ++ .../fixtures/m07_distinct_fixed_count.json | 28 ++ .../fixtures/m08_distinct_tight_domain.json | 28 ++ .../fixtures/m09_distinct_triple_tight.json | 26 ++ .../fixtures/m10_prefix_zero_values.json | 20 + .../fixtures/m11_zero_values_no_prefix.json | 19 + .../fixtures/m12_prefix_count_omitted.json | 16 + .../m13_distinct_domain_too_small.json | 19 + .../fixtures/m14_min_greater_than_max.json | 13 + .../m15_count_min_greater_than_max.json | 17 + .../fixtures/m16_distinct_string_type.json | 15 + .../m17_distinct_string_with_prefix.json | 16 + .../fixtures/m18_distinct_enum_basic.json | 36 ++ .../fixtures/m19_distinct_enum_prefix.json | 36 ++ .../fixtures/m20_distinct_enum_tight.json | 28 ++ .../fixtures/m21_distinct_enum_too_small.json | 22 + .../fixtures/m22a_backward_compat_int.json | 22 + .../fixtures/m22b_backward_compat_count.json | 26 ++ .../fixtures/m22c_backward_compat_enum.json | 24 ++ .../fixtures/m23a_unknown_field_rejected.json | 15 + .../m23b_unknown_nested_field_rejected.json | 19 + .../random-input-generator/tests/quality.rs | 138 ++++++ .../add-distinct-prefix-count/.openspec.yaml | 4 + .../add-distinct-prefix-count/design.md | 74 ++++ .../add-distinct-prefix-count/proposal.md | 35 ++ .../specs/random-input-generator/spec.md | 94 +++++ .../add-distinct-prefix-count/tasks.md | 34 ++ 39 files changed, 2014 insertions(+), 64 deletions(-) create mode 100644 .changeset/distinct-prefix-count.md create mode 100644 crates/random-input-generator/README.md create mode 100644 crates/random-input-generator/tests/conformance.rs create mode 100644 crates/random-input-generator/tests/fixtures/m01_distinct_int_basic.json create mode 100644 crates/random-input-generator/tests/fixtures/m02_prefix_int_format.json create mode 100644 crates/random-input-generator/tests/fixtures/m03_prefix_string_format.json create mode 100644 crates/random-input-generator/tests/fixtures/m04_prefix_enum_format.json create mode 100644 crates/random-input-generator/tests/fixtures/m05_prefix_custom_separator.json create mode 100644 crates/random-input-generator/tests/fixtures/m06_distinct_prefix_combined.json create mode 100644 crates/random-input-generator/tests/fixtures/m07_distinct_fixed_count.json create mode 100644 crates/random-input-generator/tests/fixtures/m08_distinct_tight_domain.json create mode 100644 crates/random-input-generator/tests/fixtures/m09_distinct_triple_tight.json create mode 100644 crates/random-input-generator/tests/fixtures/m10_prefix_zero_values.json create mode 100644 crates/random-input-generator/tests/fixtures/m11_zero_values_no_prefix.json create mode 100644 crates/random-input-generator/tests/fixtures/m12_prefix_count_omitted.json create mode 100644 crates/random-input-generator/tests/fixtures/m13_distinct_domain_too_small.json create mode 100644 crates/random-input-generator/tests/fixtures/m14_min_greater_than_max.json create mode 100644 crates/random-input-generator/tests/fixtures/m15_count_min_greater_than_max.json create mode 100644 crates/random-input-generator/tests/fixtures/m16_distinct_string_type.json create mode 100644 crates/random-input-generator/tests/fixtures/m17_distinct_string_with_prefix.json create mode 100644 crates/random-input-generator/tests/fixtures/m18_distinct_enum_basic.json create mode 100644 crates/random-input-generator/tests/fixtures/m19_distinct_enum_prefix.json create mode 100644 crates/random-input-generator/tests/fixtures/m20_distinct_enum_tight.json create mode 100644 crates/random-input-generator/tests/fixtures/m21_distinct_enum_too_small.json create mode 100644 crates/random-input-generator/tests/fixtures/m22a_backward_compat_int.json create mode 100644 crates/random-input-generator/tests/fixtures/m22b_backward_compat_count.json create mode 100644 crates/random-input-generator/tests/fixtures/m22c_backward_compat_enum.json create mode 100644 crates/random-input-generator/tests/fixtures/m23a_unknown_field_rejected.json create mode 100644 crates/random-input-generator/tests/fixtures/m23b_unknown_nested_field_rejected.json create mode 100644 crates/random-input-generator/tests/quality.rs create mode 100644 openspec/changes/add-distinct-prefix-count/.openspec.yaml create mode 100644 openspec/changes/add-distinct-prefix-count/design.md create mode 100644 openspec/changes/add-distinct-prefix-count/proposal.md create mode 100644 openspec/changes/add-distinct-prefix-count/specs/random-input-generator/spec.md create mode 100644 openspec/changes/add-distinct-prefix-count/tasks.md diff --git a/.changeset/distinct-prefix-count.md b/.changeset/distinct-prefix-count.md new file mode 100644 index 0000000..f2b328c --- /dev/null +++ b/.changeset/distinct-prefix-count.md @@ -0,0 +1,8 @@ +--- +"@cxphoenix/vp-wasm-coding": minor +--- + +`generate_challenge` params 支援兩個新的參數規格頂層欄位(預設皆為 `false`,未宣告時行為完全不變): + +- `distinct: true` — 同一行抽出的值兩兩相異。`int` 與 `enum`(values 先去重)支援;字串型別與 `faker` 宣告即於建構期報錯。值域不足 `count.max` 時建構期報錯;值域恰好等於 `count.max` 時輸出為隨機排列。 +- `prefix_count: true` — 行首以 `count.separator` join 語意輸出實際個數 `n`(APCS 慣例的 `n x1 … xn` 格式);適用所有型別;`n = 0` 時該行輸出恰為 `0`。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ff017f..8732bf8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,8 +51,17 @@ jobs: - name: Build generator WASM run: pnpm build:wasm + # --all-features: the feature-gated faker paths (incl. its distinct + # rejection) are otherwise never compiled or run in CI. - name: Test Rust crate - run: cargo test + run: cargo test --all-features + working-directory: crates/random-input-generator + + # Release-config run is mandatory: it proves construction-time validation + # (error-path fixtures) survives release builds, where debug_assert! is + # stripped. Also the only config where the Q3 performance tests run. + - name: Test Rust crate (release) + run: cargo test --release --all-features working-directory: crates/random-input-generator - name: Build packages diff --git a/crates/random-input-generator/README.md b/crates/random-input-generator/README.md new file mode 100644 index 0000000..b2d5598 --- /dev/null +++ b/crates/random-input-generator/README.md @@ -0,0 +1,127 @@ +# random-input-generator + +Rust→WASM random stdin generator for `vp-wasm-coding`. The public entry point +`generate_challenge(params_json, count)` takes a JSON *params* object — an +ordered map of `name → parameter spec`, one output line per parameter — and +returns `count` random stdin strings. + +```json +{ + "plaintext": { "type": "alpha_upper", "min_len": 5, "max_len": 12 }, + "shift": { "type": "int", "min": 1, "max": 25 } +} +``` + +## Parameter spec + +### Common fields (all types) + +| Field | Default | Meaning | +| ----- | ------- | ------- | +| `count.min` / `count.max` | `1` / `1` | The actual number of values `n` is drawn uniformly from `[count.min, count.max]`. Omitting `count` entirely is equivalent to `{"min": 1, "max": 1, "separator": " "}`. | +| `count.separator` | `" "` | Separator joined between tokens on the line. | +| `distinct` | `false` | Values within the line are pairwise distinct (see below). | +| `prefix_count` | `false` | The line starts with the actual count `n` (see below). | + +`count.max` is capped at `10_000`; string lengths at `100_000`; the testcase +`count` argument of `generate_challenge` at `10_000`. All validation runs at +construction time (`parse_params`) and is active in **release builds** — +invalid specs return a descriptive error, they never panic downstream. + +**Unknown keys are rejected.** A misspelled field (`"distnct"`, +`"prefix-count"`, `count.seperator`) is a construction-time error, not a +silently ignored key — a typo'd opt-in flag must never silently disable the +guarantee it was meant to enable. + +**Separators are the author's responsibility.** `count.separator` is joined +verbatim and not validated: an empty string, a digit, a newline, or a character +that can occur inside the values themselves (e.g. `,` with `printable_ascii`) +produces output that may be ambiguous to re-split or span multiple lines. +Choose a separator that cannot collide with your value alphabet. + +### Types + +| `type` | Fields (defaults) | +| ------ | ----------------- | +| `int` | `min` (0), `max` (100) — 64-bit signed | +| `alpha_upper`, `alpha_lower`, `alpha_mixed`, `hex_string`, `printable_ascii` | `min_len` (1), `max_len` (255), `multiple_of` (1) | +| `enum` | `values` — non-empty string array (required) | +| `faker` (feature-gated) | `category` — `name`, `first_name`, `last_name`, `email`, `company`, `city`, `country` | + +## `distinct: true` — pairwise-distinct values + +All values generated for the parameter within one line (one `count` batch) are +pairwise distinct. Distinctness across different parameters is not guaranteed. + +Per-type support: + +| Type | Support | Domain size | +| ---- | ------- | ----------- | +| `int` | supported | `max − min + 1` (computed overflow-safe; the full i64 range is fine) | +| `enum` | supported | number of **deduplicated** `values` | +| string types | **rejected** — construction-time error | — | +| `faker` | **rejected** — construction-time error | — | + +Construction-time validation (in order): + +1. Basic bounds — `min ≤ max`, `count.min ≤ count.max` — always active, + regardless of `distinct`. +2. Domain size ≥ `count.max`, otherwise a construction-time error. The + generator never silently emits duplicates and never loops forever. + +Behavioral guarantees: + +- When the domain size equals `count.max` (the tight case), the output is a + random permutation of the domain. +- The output order is random — never a fixed sorted order (sorted output would + leak problem structure, e.g. "k-th smallest" tasks). + +## `prefix_count: true` — APCS-style count prefix + +The actual count `n` and the `n` values form **one token sequence** joined by +`count.separator` (join semantics — the separator only appears between tokens): + +```text +count: {min: 5, max: 8}, prefix_count: true → 6 x1 x2 x3 x4 x5 x6 +separator "," → 3,x1,x2,x3 +``` + +- `n` is the *actual* drawn count, not `count.max`. +- Applies to **all** types (it only concerns the count, not the values). +- `n = 0` (possible when `count.min` is 0): the line is exactly `0` — no + separator, no values. Without `prefix_count`, `n = 0` stays an empty line. +- `count` omitted: the line is `1value`. +- Combining with `distinct` is allowed, but `prefix_count` does **not** relax + the `distinct` type rules — `distinct` on a string type is an error with or + without `prefix_count`. + +Both fields default to `false`; specs that don't declare them behave exactly as +before they existed. + +## Example (APCS-style line, distinct values) + +```json +{ + "numbers": { + "type": "int", "min": 1, "max": 1000000, + "count": { "min": 5, "max": 20, "separator": " " }, + "distinct": true, "prefix_count": true + } +} +``` + +Possible output line: `7 42 981 5 100003 77 6 314159` + +## Testing + +- `cargo test` — unit tests plus the conformance harness + (`tests/conformance.rs`), which runs the language-neutral JSON fixtures in + `tests/fixtures/` covering the requirement's traceability matrix M1–M22. +- `cargo test --release` — mandatory in CI: proves the error-path validation + survives release builds (no `debug_assert!`-only checks) and runs the Q3 + performance tests. +- `tests/quality.rs` — fixed-seed statistical smoke tests: Q1 output-order + (no systematic sorting), Q2 chi-squared uniformity, Q3 performance + (single line under 100 ms for `n = 10^4` from `[1, 10^9]`, and for a full + `[1, 10^4]` permutation). Performance reference environment: **native + release build** (not WASM). diff --git a/crates/random-input-generator/src/lib.rs b/crates/random-input-generator/src/lib.rs index a0b3953..e8fc0b9 100644 --- a/crates/random-input-generator/src/lib.rs +++ b/crates/random-input-generator/src/lib.rs @@ -1,11 +1,17 @@ mod parser; mod rng; +use rand::Rng; use rand::SeedableRng; use rand::rngs::SmallRng; use serde::Serialize; use wasm_bindgen::prelude::*; +/// Upper bound on the number of testcase inputs per call. Keeps a huge `count` +/// from the JS side from multiplying into unbounded allocation (each input can +/// legally reach `MAX_COUNT` values of up to `MAX_LEN` chars). +const MAX_TESTCASES: usize = 10_000; + /// Output of `generate_challenge`: a list of random stdin input strings, /// one per testcase. The frontend feeds each to the Python generator to /// produce the corresponding expected output. @@ -14,6 +20,25 @@ struct GeneratedInputs { inputs: Vec, } +/// Native entry point: parse a JSON params specification and generate `count` +/// stdin input strings with a caller-provided RNG. +/// +/// This is the only supported way to drive the generator outside WASM +/// (integration tests, future native hosts). The parse → generate pipeline is +/// inseparable by design: every spec that reaches sampling has passed +/// construction-time validation, so generation cannot panic. +pub fn generate( + params_json: &str, + count: usize, + rng: &mut impl Rng, +) -> Result, String> { + if count > MAX_TESTCASES { + return Err(format!("count ({count}) exceeds limit {MAX_TESTCASES}")); + } + let params = parser::parse_params(params_json)?; + Ok((0..count).map(|_| rng::generate_input(¶ms, rng)).collect()) +} + /// Generate random input strings from a JSON params specification. /// /// # Arguments @@ -25,11 +50,8 @@ struct GeneratedInputs { /// `{ inputs: [string, ...] }` — one input string per testcase. #[wasm_bindgen] pub fn generate_challenge(params_json: &str, count: usize) -> Result { - let params = parser::parse_params(params_json).map_err(|e| JsError::new(&e))?; let mut rng = SmallRng::from_entropy(); - let inputs: Vec = (0..count) - .map(|_| rng::generate_input(¶ms, &mut rng)) - .collect(); + let inputs = generate(params_json, count, &mut rng).map_err(|e| JsError::new(&e))?; let result = GeneratedInputs { inputs }; serde_wasm_bindgen::to_value(&result).map_err(|e| JsError::new(&e.to_string())) } @@ -39,13 +61,10 @@ mod tests { use super::*; #[test] - fn generate_challenge_returns_correct_count() { + fn generate_returns_correct_count() { let json = r#"{"shift": {"type": "int", "min": 1, "max": 25}}"#; - let params = parser::parse_params(json).unwrap(); let mut rng = SmallRng::seed_from_u64(42); - let inputs: Vec = (0..5) - .map(|_| rng::generate_input(¶ms, &mut rng)) - .collect(); + let inputs = generate(json, 5, &mut rng).unwrap(); assert_eq!(inputs.len(), 5); for input in &inputs { let v: i64 = input.parse().unwrap(); @@ -54,8 +73,23 @@ mod tests { } #[test] - fn generate_challenge_invalid_params_json() { - let result = parser::parse_params("not json"); - assert!(result.is_err()); + fn generate_invalid_params_json_errors() { + let mut rng = SmallRng::seed_from_u64(42); + assert!(generate("not json", 1, &mut rng).is_err()); + } + + #[test] + fn generate_count_over_limit_errors() { + let json = r#"{"shift": {"type": "int", "min": 1, "max": 25}}"#; + let mut rng = SmallRng::seed_from_u64(42); + let err = generate(json, MAX_TESTCASES + 1, &mut rng).unwrap_err(); + assert!(err.contains("exceeds limit"), "got: {err}"); + } + + #[test] + fn generate_count_at_limit_is_ok() { + let json = r#"{"shift": {"type": "int", "min": 1, "max": 25}}"#; + let mut rng = SmallRng::seed_from_u64(42); + assert_eq!(generate(json, MAX_TESTCASES, &mut rng).unwrap().len(), MAX_TESTCASES); } } diff --git a/crates/random-input-generator/src/parser.rs b/crates/random-input-generator/src/parser.rs index 0bab972..0ceab23 100644 --- a/crates/random-input-generator/src/parser.rs +++ b/crates/random-input-generator/src/parser.rs @@ -1,5 +1,6 @@ use indexmap::IndexMap; use serde::Deserialize; +use std::collections::HashSet; // Define default values if not passing in. fn default_min_len() -> usize { 1 } @@ -18,7 +19,12 @@ fn default_separator() -> String { " ".to_string() } /// {"min": 2, "max": 5, "separator": ","} /// ``` /// All fields are optional; omitting the whole `count` key uses the defaults. +// deny_unknown_fields: a typo'd key (e.g. "seperator") must fail loudly instead +// of being silently dropped. NOTE: this attribute is incompatible with +// #[serde(flatten)] — if R3 (dataset composite types) ever needs flatten, this +// guarantee has to be re-provided another way. #[derive(Debug, Deserialize, Clone, PartialEq)] +#[serde(deny_unknown_fields)] pub struct CountSpec { #[serde(default = "default_count_min")] pub min: usize, @@ -36,8 +42,11 @@ impl Default for CountSpec { /// Supported parameter types for randomisation. /// Serialised in JSON with `"type"` as a discriminant tag. +// deny_unknown_fields: a typo'd opt-in flag ("distnct", "prefix-count") would +// otherwise silently default to false — a silent bypass of the guarantee the +// flag exists to provide (and of its construction-time validation). #[derive(Debug, Deserialize, Clone, PartialEq)] -#[serde(tag = "type", rename_all = "snake_case")] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum ParamSpec { Int { #[serde(default = "default_min_int")] @@ -46,6 +55,10 @@ pub enum ParamSpec { max: i64, #[serde(default)] count: CountSpec, + #[serde(default)] + distinct: bool, + #[serde(default)] + prefix_count: bool, }, AlphaUpper { #[serde(default = "default_min_len")] @@ -56,6 +69,10 @@ pub enum ParamSpec { multiple_of: usize, #[serde(default)] count: CountSpec, + #[serde(default)] + distinct: bool, + #[serde(default)] + prefix_count: bool, }, AlphaLower { #[serde(default = "default_min_len")] @@ -66,6 +83,10 @@ pub enum ParamSpec { multiple_of: usize, #[serde(default)] count: CountSpec, + #[serde(default)] + distinct: bool, + #[serde(default)] + prefix_count: bool, }, AlphaMixed { #[serde(default = "default_min_len")] @@ -76,6 +97,10 @@ pub enum ParamSpec { multiple_of: usize, #[serde(default)] count: CountSpec, + #[serde(default)] + distinct: bool, + #[serde(default)] + prefix_count: bool, }, HexString { #[serde(default = "default_min_len")] @@ -86,6 +111,10 @@ pub enum ParamSpec { multiple_of: usize, #[serde(default)] count: CountSpec, + #[serde(default)] + distinct: bool, + #[serde(default)] + prefix_count: bool, }, PrintableAscii { #[serde(default = "default_min_len")] @@ -96,17 +125,29 @@ pub enum ParamSpec { multiple_of: usize, #[serde(default)] count: CountSpec, + #[serde(default)] + distinct: bool, + #[serde(default)] + prefix_count: bool, }, Enum { values: Vec, #[serde(default)] count: CountSpec, + #[serde(default)] + distinct: bool, + #[serde(default)] + prefix_count: bool, }, #[cfg(feature = "faker")] Faker { category: FakerCategory, #[serde(default)] count: CountSpec, + #[serde(default)] + distinct: bool, + #[serde(default)] + prefix_count: bool, }, } @@ -135,6 +176,18 @@ pub type Params = IndexMap; const MAX_LEN: usize = 100_000; const MAX_COUNT: usize = 10_000; +/// Ensure a distinct-enabled param can always fill a batch of `count.max` +/// pairwise-distinct values. Failing at construction time is mandatory: +/// sampling would otherwise loop forever (rejection) or silently repeat. +fn validate_distinct_domain(name: &str, domain_size: i128, count_max: usize) -> Result<(), String> { + if domain_size < count_max as i128 { + return Err(format!( + "param '{name}': distinct requires domain size ({domain_size}) >= count.max ({count_max})" + )); + } + Ok(()) +} + fn validate_count(name: &str, count: &CountSpec) -> Result<(), String> { if count.min > count.max { return Err(format!( @@ -200,30 +253,50 @@ pub fn parse_params(json_str: &str) -> Result { .map_err(|e| format!("JSON parse error: {e}"))?; for (name, spec) in ¶ms { match spec { - ParamSpec::Int { min, max, count } => { + ParamSpec::Int { min, max, count, distinct, .. } => { if min > max { return Err(format!( "param '{name}': min ({min}) must be <= max ({max})" )); } validate_count(name, count)?; + if *distinct { + // Widened arithmetic: the full i64 range would overflow + // `max - min + 1` in i64, so compute the domain size in i128. + let domain_size = (*max as i128) - (*min as i128) + 1; + validate_distinct_domain(name, domain_size, count.max)?; + } } - ParamSpec::AlphaUpper { min_len, max_len, multiple_of, count } - | ParamSpec::AlphaLower { min_len, max_len, multiple_of, count } - | ParamSpec::AlphaMixed { min_len, max_len, multiple_of, count } - | ParamSpec::HexString { min_len, max_len, multiple_of, count } - | ParamSpec::PrintableAscii { min_len, max_len, multiple_of, count } => { + ParamSpec::AlphaUpper { min_len, max_len, multiple_of, count, distinct, .. } + | ParamSpec::AlphaLower { min_len, max_len, multiple_of, count, distinct, .. } + | ParamSpec::AlphaMixed { min_len, max_len, multiple_of, count, distinct, .. } + | ParamSpec::HexString { min_len, max_len, multiple_of, count, distinct, .. } + | ParamSpec::PrintableAscii { min_len, max_len, multiple_of, count, distinct, .. } => { + if *distinct { + return Err(format!( + "param '{name}': distinct is not supported for string types" + )); + } validate_len(name, *min_len, *max_len, *multiple_of)?; validate_count(name, count)?; } - ParamSpec::Enum { values, count } => { + ParamSpec::Enum { values, count, distinct, .. } => { if values.is_empty() { return Err(format!("param '{name}': enum values must not be empty")); } validate_count(name, count)?; + if *distinct { + let dedup_size = values.iter().collect::>().len(); + validate_distinct_domain(name, dedup_size as i128, count.max)?; + } } #[cfg(feature = "faker")] - ParamSpec::Faker { count, .. } => { + ParamSpec::Faker { count, distinct, .. } => { + if *distinct { + return Err(format!( + "param '{name}': distinct is not supported for faker types" + )); + } validate_count(name, count)?; } } @@ -235,25 +308,168 @@ pub fn parse_params(json_str: &str) -> Result { mod tests { use super::*; + #[test] + fn distinct_and_prefix_count_default_to_false() { + let json = r#"{"n": {"type": "int", "min": 1, "max": 10}}"#; + let params = parse_params(json).unwrap(); + match ¶ms["n"] { + ParamSpec::Int { distinct, prefix_count, .. } => { + assert!(!distinct); + assert!(!prefix_count); + } + other => panic!("expected Int, got {other:?}"), + } + } + + #[test] + fn parses_distinct_and_prefix_count_on_int() { + let json = r#"{"n": {"type": "int", "min": 1, "max": 100, "count": {"min": 5, "max": 20}, "distinct": true, "prefix_count": true}}"#; + let params = parse_params(json).unwrap(); + match ¶ms["n"] { + ParamSpec::Int { distinct, prefix_count, .. } => { + assert!(distinct); + assert!(prefix_count); + } + other => panic!("expected Int, got {other:?}"), + } + } + + #[test] + fn parses_distinct_on_enum() { + let json = r#"{"c": {"type": "enum", "values": ["r", "g", "b"], "count": {"min": 2, "max": 3}, "distinct": true}}"#; + let params = parse_params(json).unwrap(); + match ¶ms["c"] { + ParamSpec::Enum { distinct, prefix_count, .. } => { + assert!(distinct); + assert!(!prefix_count); + } + other => panic!("expected Enum, got {other:?}"), + } + } + + #[test] + fn parses_prefix_count_on_string_type() { + let json = r#"{"s": {"type": "alpha_upper", "min_len": 2, "max_len": 4, "prefix_count": true}}"#; + let params = parse_params(json).unwrap(); + match ¶ms["s"] { + ParamSpec::AlphaUpper { distinct, prefix_count, .. } => { + assert!(!distinct); + assert!(prefix_count); + } + other => panic!("expected AlphaUpper, got {other:?}"), + } + } + + // ── unknown fields are rejected (typo'd flags must not silently disable) ── + + #[test] + fn misspelled_distinct_field_returns_error() { + let json = r#"{"n": {"type": "int", "min": 1, "max": 10, "distnct": true}}"#; + let err = parse_params(json).unwrap_err(); + assert!(err.contains("unknown field"), "got: {err}"); + } + + #[test] + fn hyphenated_prefix_count_field_returns_error() { + let json = r#"{"n": {"type": "int", "min": 1, "max": 10, "prefix-count": true}}"#; + let err = parse_params(json).unwrap_err(); + assert!(err.contains("unknown field"), "got: {err}"); + } + + #[test] + fn misspelled_nested_count_field_returns_error() { + let json = r#"{"n": {"type": "int", "min": 1, "max": 10, "count": {"min": 2, "max": 3, "seperator": ","}}}"#; + let err = parse_params(json).unwrap_err(); + assert!(err.contains("unknown field"), "got: {err}"); + } + + // ── distinct construction-time validation (M13, M16, M17, M21, overflow) ── + + #[test] + fn distinct_domain_smaller_than_count_max_returns_error() { + // M13: domain size 3 < count.max 5 + let json = r#"{"n": {"type": "int", "min": 1, "max": 3, "count": {"min": 5, "max": 5}, "distinct": true}}"#; + let err = parse_params(json).unwrap_err(); + assert!(err.contains("n"), "error should name the param, got: {err}"); + assert!(err.contains("distinct"), "error should mention distinct, got: {err}"); + } + + #[test] + fn distinct_domain_exactly_count_max_is_valid() { + // M8 boundary: domain size == count.max is legal (permutation case) + let json = r#"{"n": {"type": "int", "min": 1, "max": 5, "count": {"min": 5, "max": 5}, "distinct": true}}"#; + assert!(parse_params(json).is_ok()); + } + + #[test] + fn distinct_on_string_type_returns_error() { + // M16: string types do not support distinct + let json = r#"{"s": {"type": "alpha_upper", "min_len": 1, "max_len": 5, "distinct": true}}"#; + let err = parse_params(json).unwrap_err(); + assert!(err.contains("s"), "error should name the param, got: {err}"); + assert!(err.contains("distinct"), "error should mention distinct, got: {err}"); + } + + #[test] + fn distinct_on_string_type_with_prefix_count_still_errors() { + // M17: prefix_count does not relax the distinct type restriction + let json = r#"{"s": {"type": "hex_string", "min_len": 1, "max_len": 5, "distinct": true, "prefix_count": true}}"#; + assert!(parse_params(json).is_err()); + } + + #[test] + fn distinct_enum_dedup_domain_too_small_returns_error() { + // M21: deduplicated values count 2 < count.max 3 + let json = r#"{"c": {"type": "enum", "values": ["a", "b", "a"], "count": {"min": 3, "max": 3}, "distinct": true}}"#; + let err = parse_params(json).unwrap_err(); + assert!(err.contains("c"), "error should name the param, got: {err}"); + } + + #[test] + fn distinct_enum_dedup_domain_exactly_count_max_is_valid() { + // M20 boundary: deduplicated domain == count.max + let json = r#"{"c": {"type": "enum", "values": ["a", "b", "a", "c"], "count": {"min": 3, "max": 3}, "distinct": true}}"#; + assert!(parse_params(json).is_ok()); + } + + #[test] + fn distinct_full_i64_range_does_not_overflow() { + // Domain size computation must be overflow-safe across the full i64 range + let json = format!( + r#"{{"n": {{"type": "int", "min": {}, "max": {}, "count": {{"min": 1, "max": 100}}, "distinct": true}}}}"#, + i64::MIN, i64::MAX + ); + assert!(parse_params(&json).is_ok()); + } + + #[test] + fn basic_bounds_checks_active_regardless_of_distinct() { + // M14/M15 stay in effect whether or not distinct is declared + let with_distinct = r#"{"n": {"type": "int", "min": 100, "max": 10, "distinct": true}}"#; + assert!(parse_params(with_distinct).is_err()); + let without = r#"{"n": {"type": "int", "min": 1, "max": 10, "count": {"min": 5, "max": 2}, "distinct": true}}"#; + assert!(parse_params(without).is_err()); + } + #[test] fn parses_int_param() { let json = r#"{"shift": {"type": "int", "min": 1, "max": 25}}"#; let params = parse_params(json).unwrap(); - assert_eq!(params["shift"], ParamSpec::Int { min: 1, max: 25, count: CountSpec::default() }); + assert_eq!(params["shift"], ParamSpec::Int { min: 1, max: 25, count: CountSpec::default(), distinct: false, prefix_count: false }); } #[test] fn parses_alpha_upper_param() { let json = r#"{"pt": {"type": "alpha_upper", "min_len": 5, "max_len": 12}}"#; let params = parse_params(json).unwrap(); - assert_eq!(params["pt"], ParamSpec::AlphaUpper { min_len: 5, max_len: 12, multiple_of: 1, count: CountSpec::default() }); + assert_eq!(params["pt"], ParamSpec::AlphaUpper { min_len: 5, max_len: 12, multiple_of: 1, count: CountSpec::default(), distinct: false, prefix_count: false }); } #[test] fn parses_hex_string_param() { let json = r#"{"k": {"type": "hex_string", "min_len": 32, "max_len": 32}}"#; let params = parse_params(json).unwrap(); - assert_eq!(params["k"], ParamSpec::HexString { min_len: 32, max_len: 32, multiple_of: 1, count: CountSpec::default() }); + assert_eq!(params["k"], ParamSpec::HexString { min_len: 32, max_len: 32, multiple_of: 1, count: CountSpec::default(), distinct: false, prefix_count: false }); } #[test] @@ -282,21 +498,21 @@ mod tests { fn parses_alpha_lower_param() { let json = r#"{"pt": {"type": "alpha_lower", "min_len": 3, "max_len": 8}}"#; let params = parse_params(json).unwrap(); - assert_eq!(params["pt"], ParamSpec::AlphaLower { min_len: 3, max_len: 8, multiple_of: 1, count: CountSpec::default() }); + assert_eq!(params["pt"], ParamSpec::AlphaLower { min_len: 3, max_len: 8, multiple_of: 1, count: CountSpec::default(), distinct: false, prefix_count: false }); } #[test] fn parses_alpha_mixed_param() { let json = r#"{"pt": {"type": "alpha_mixed", "min_len": 4, "max_len": 16}}"#; let params = parse_params(json).unwrap(); - assert_eq!(params["pt"], ParamSpec::AlphaMixed { min_len: 4, max_len: 16, multiple_of: 1, count: CountSpec::default() }); + assert_eq!(params["pt"], ParamSpec::AlphaMixed { min_len: 4, max_len: 16, multiple_of: 1, count: CountSpec::default(), distinct: false, prefix_count: false }); } #[test] fn parses_printable_ascii_param() { let json = r#"{"msg": {"type": "printable_ascii", "min_len": 10, "max_len": 20}}"#; let params = parse_params(json).unwrap(); - assert_eq!(params["msg"], ParamSpec::PrintableAscii { min_len: 10, max_len: 20, multiple_of: 1, count: CountSpec::default() }); + assert_eq!(params["msg"], ParamSpec::PrintableAscii { min_len: 10, max_len: 20, multiple_of: 1, count: CountSpec::default(), distinct: false, prefix_count: false }); } #[test] @@ -308,6 +524,8 @@ mod tests { min: 1, max: 25, count: CountSpec { min: 3, max: 3, separator: " ".to_string() }, + distinct: false, + prefix_count: false, }); } @@ -317,7 +535,7 @@ mod tests { let params = parse_params(json).unwrap(); assert_eq!( params["pt"], - ParamSpec::AlphaUpper { min_len: 5, max_len: 10, multiple_of: 1, count: CountSpec::default() } + ParamSpec::AlphaUpper { min_len: 5, max_len: 10, multiple_of: 1, count: CountSpec::default(), distinct: false, prefix_count: false } ); } @@ -326,8 +544,8 @@ mod tests { let json = r#"{"a": {"type": "int", "min": 1, "max": 10}, "b": {"type": "alpha_lower", "min_len": 3, "max_len": 5}}"#; let params = parse_params(json).unwrap(); assert_eq!(params.len(), 2); - assert_eq!(params["a"], ParamSpec::Int { min: 1, max: 10, count: CountSpec::default() }); - assert_eq!(params["b"], ParamSpec::AlphaLower { min_len: 3, max_len: 5, multiple_of: 1, count: CountSpec::default() }); + assert_eq!(params["a"], ParamSpec::Int { min: 1, max: 10, count: CountSpec::default(), distinct: false, prefix_count: false }); + assert_eq!(params["b"], ParamSpec::AlphaLower { min_len: 3, max_len: 5, multiple_of: 1, count: CountSpec::default(), distinct: false, prefix_count: false }); } #[test] @@ -339,6 +557,8 @@ mod tests { min: 1, max: 100, count: CountSpec { min: 2, max: 5, separator: " ".to_string() }, + distinct: false, + prefix_count: false, }); } @@ -349,6 +569,8 @@ mod tests { assert_eq!(params["mode"], ParamSpec::Enum { values: vec!["ECB".to_string(), "CBC".to_string()], count: CountSpec::default(), + distinct: false, + prefix_count: false, }); } @@ -418,6 +640,8 @@ mod tests { assert_eq!(params["mode"], ParamSpec::Enum { values: vec!["A".to_string(), "B".to_string(), "C".to_string()], count: CountSpec { min: 2, max: 3, separator: ",".to_string() }, + distinct: false, + prefix_count: false, }); } @@ -429,6 +653,8 @@ mod tests { min: 1, max: 10, count: CountSpec { min: 3, max: 3, separator: ",".to_string() }, + distinct: false, + prefix_count: false, }); } @@ -441,6 +667,8 @@ mod tests { max_len: 64, multiple_of: 16, count: CountSpec::default(), + distinct: false, + prefix_count: false, }); } @@ -453,6 +681,8 @@ mod tests { max_len: 16, multiple_of: 1, count: CountSpec::default(), + distinct: false, + prefix_count: false, }); } @@ -466,6 +696,14 @@ mod tests { assert!(err.contains("unknown variant"), "expected unknown variant error, got: {err}"); } + #[cfg(feature = "faker")] + #[test] + fn distinct_on_faker_returns_error() { + let json = r#"{"name": {"type": "faker", "category": "name", "distinct": true}}"#; + let err = parse_params(json).unwrap_err(); + assert!(err.contains("distinct"), "error should mention distinct, got: {err}"); + } + #[cfg(feature = "faker")] #[test] fn parses_faker_param_with_feature() { @@ -474,6 +712,8 @@ mod tests { assert_eq!(params["name"], ParamSpec::Faker { category: FakerCategory::Name, count: CountSpec::default(), + distinct: false, + prefix_count: false, }); } } diff --git a/crates/random-input-generator/src/rng.rs b/crates/random-input-generator/src/rng.rs index 2ddd5b6..76c65bd 100644 --- a/crates/random-input-generator/src/rng.rs +++ b/crates/random-input-generator/src/rng.rs @@ -1,12 +1,13 @@ use indexmap::IndexMap; use rand::Rng; +use std::collections::HashSet; #[cfg(feature = "faker")] use fake::Fake; #[cfg(feature = "faker")] use crate::parser::FakerCategory; -use crate::parser::ParamSpec; +use crate::parser::{CountSpec, ParamSpec}; const UPPER: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const LOWER: &[u8] = b"abcdefghijklmnopqrstuvwxyz"; @@ -24,25 +25,118 @@ pub fn generate_input(specs: &IndexMap, rng: &mut R) } fn generate_one(spec: &ParamSpec, rng: &mut R) -> String { - let count_spec = match spec { - ParamSpec::Int { count, .. } => count, - ParamSpec::AlphaUpper { count, .. } => count, - ParamSpec::AlphaLower { count, .. } => count, - ParamSpec::AlphaMixed { count, .. } => count, - ParamSpec::HexString { count, .. } => count, - ParamSpec::PrintableAscii { count, .. } => count, - ParamSpec::Enum { count, .. } => count, - #[cfg(feature = "faker")] - ParamSpec::Faker { count, .. } => count, + // `parse_params` guarantees count.min <= count.max, so gen_range cannot panic. + let common = common_fields(spec); + + let actual_count = rng.gen_range(common.count.min..=common.count.max); + let values = if common.distinct { + generate_distinct(spec, actual_count, rng) + } else { + (0..actual_count).map(|_| generate_single(spec, rng)).collect() + }; + // Join semantics: with prefix_count the actual count `n` is just the first + // token of the same sequence — the separator only appears between tokens, + // so `n = 0` yields exactly "0" and the non-prefixed empty line stays "". + let tokens: Vec = if common.prefix_count { + std::iter::once(actual_count.to_string()).chain(values).collect() + } else { + values }; + tokens.join(&common.count.separator) +} - debug_assert!(count_spec.min <= count_spec.max, "CountSpec.min must be <= max"); +/// The fields shared by every `ParamSpec` variant, extracted by name — the +/// named struct (instead of a positional tuple) makes a distinct/prefix_count +/// transposition in any match arm a compile error rather than a silent swap. +struct CommonFields<'a> { + count: &'a CountSpec, + distinct: bool, + prefix_count: bool, +} - let actual_count = rng.gen_range(count_spec.min..=count_spec.max); - (0..actual_count) - .map(|_| generate_single(spec, rng)) - .collect::>() - .join(&count_spec.separator) +fn common_fields(spec: &ParamSpec) -> CommonFields<'_> { + match spec { + ParamSpec::Int { count, distinct, prefix_count, .. } + | ParamSpec::AlphaUpper { count, distinct, prefix_count, .. } + | ParamSpec::AlphaLower { count, distinct, prefix_count, .. } + | ParamSpec::AlphaMixed { count, distinct, prefix_count, .. } + | ParamSpec::HexString { count, distinct, prefix_count, .. } + | ParamSpec::PrintableAscii { count, distinct, prefix_count, .. } + | ParamSpec::Enum { count, distinct, prefix_count, .. } => CommonFields { + count, + distinct: *distinct, + prefix_count: *prefix_count, + }, + #[cfg(feature = "faker")] + ParamSpec::Faker { count, distinct, prefix_count, .. } => CommonFields { + count, + distinct: *distinct, + prefix_count: *prefix_count, + }, + } +} + +/// Threshold factor for choosing the distinct-sampling strategy: domains up to +/// `EXPAND_FACTOR × count.max` are materialised and partially shuffled (exact, +/// handles the tight permutation case); larger domains use rejection sampling, +/// where the per-draw collision probability stays below 1/EXPAND_FACTOR. +const EXPAND_FACTOR: i128 = 4; + +/// Sample `n` pairwise-distinct values for a distinct-enabled spec. +/// Only `int` and `enum` reach here; `parse_params` rejects `distinct: true` +/// on every other type at construction time. +fn generate_distinct(spec: &ParamSpec, n: usize, rng: &mut R) -> Vec { + match spec { + ParamSpec::Int { min, max, count, .. } => { + let domain_size = (*max as i128) - (*min as i128) + 1; + if domain_size <= EXPAND_FACTOR * count.max as i128 { + // Small domain: materialise and partially shuffle. Covers the + // tight case (domain == count.max → random permutation). + let pool: Vec = (*min..=*max).collect(); + partial_shuffle_take(pool, n, rng) + .into_iter() + .map(|v| v.to_string()) + .collect() + } else { + // Large domain: rejection sampling. parse_params guarantees + // domain_size >= count.max >= n, so this terminates; with + // domain > EXPAND_FACTOR × count.max the expected number of + // retries per draw is below 1/(EXPAND_FACTOR - 1). + let mut seen = HashSet::with_capacity(n); + let mut out = Vec::with_capacity(n); + while out.len() < n { + let v = rng.gen_range(*min..=*max); + if seen.insert(v) { + out.push(v.to_string()); + } + } + out + } + } + ParamSpec::Enum { values, .. } => { + // Deduplicate preserving first occurrence, then partially shuffle. + let mut seen = HashSet::new(); + let pool: Vec = values + .iter() + .filter(|v| seen.insert(v.as_str())) + .cloned() + .collect(); + partial_shuffle_take(pool, n, rng) + } + _ => unreachable!("distinct on unsupported types is rejected by parse_params"), + } +} + +/// Partial Fisher–Yates: shuffle the first `n` positions of `pool` and keep +/// them. The result is a uniformly random n-subset in uniformly random order. +/// Caller guarantees `n <= pool.len()` (enforced by parse_params). +fn partial_shuffle_take(mut pool: Vec, n: usize, rng: &mut R) -> Vec { + for i in 0..n { + let j = rng.gen_range(i..pool.len()); + pool.swap(i, j); + } + pool.truncate(n); + pool } /// Pick a random length in [min_len, max_len] that is a multiple of `multiple_of`. @@ -53,7 +147,8 @@ fn random_len(min_len: usize, max_len: usize, multiple_of: usize, rng: & let lo = (min_len + step - 1) / step; // Largest multiple of `step` that is <= max_len let hi = max_len / step; - debug_assert!(lo <= hi, "no valid length: min_len={min_len}, max_len={max_len}, multiple_of={step}"); + // `parse_params::validate_len` guarantees at least one multiple of `step` + // lies in [min_len, max_len], so lo <= hi holds and gen_range cannot panic. rng.gen_range(lo..=hi) * step } @@ -137,7 +232,7 @@ mod tests { #[test] fn int_within_range() { let mut rng = seeded(); - let spec = ParamSpec::Int { min: 1, max: 25, count: CountSpec::default() }; + let spec = ParamSpec::Int { min: 1, max: 25, count: CountSpec::default(), distinct: false, prefix_count: false }; for _ in 0..100 { let v: i64 = generate_one(&spec, &mut rng).parse().unwrap(); assert!((1..=25).contains(&v)); @@ -147,7 +242,7 @@ mod tests { #[test] fn alpha_upper_only_uppercase() { let mut rng = seeded(); - let spec = ParamSpec::AlphaUpper { min_len: 5, max_len: 10, multiple_of: 1, count: CountSpec::default() }; + let spec = ParamSpec::AlphaUpper { min_len: 5, max_len: 10, multiple_of: 1, count: CountSpec::default(), distinct: false, prefix_count: false }; let v = generate_one(&spec, &mut rng); assert!(v.chars().all(|c| c.is_ascii_uppercase())); assert!((5..=10).contains(&v.len())); @@ -156,14 +251,14 @@ mod tests { #[test] fn hex_string_valid_chars() { let mut rng = seeded(); - let spec = ParamSpec::HexString { min_len: 4, max_len: 8, multiple_of: 1, count: CountSpec::default() }; + let spec = ParamSpec::HexString { min_len: 4, max_len: 8, multiple_of: 1, count: CountSpec::default(), distinct: false, prefix_count: false }; let v = generate_one(&spec, &mut rng); assert!(v.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn deterministic_with_seed() { - let spec = ParamSpec::Int { min: 0, max: 1000, count: CountSpec::default() }; + let spec = ParamSpec::Int { min: 0, max: 1000, count: CountSpec::default(), distinct: false, prefix_count: false }; let v1 = generate_one(&spec, &mut SmallRng::seed_from_u64(7)); let v2 = generate_one(&spec, &mut SmallRng::seed_from_u64(7)); assert_eq!(v1, v2); @@ -172,7 +267,7 @@ mod tests { #[test] fn alpha_lower_only_lowercase() { let mut rng = seeded(); - let spec = ParamSpec::AlphaLower { min_len: 5, max_len: 10, multiple_of: 1, count: CountSpec::default() }; + let spec = ParamSpec::AlphaLower { min_len: 5, max_len: 10, multiple_of: 1, count: CountSpec::default(), distinct: false, prefix_count: false }; let v = generate_one(&spec, &mut rng); assert!(v.chars().all(|c| c.is_ascii_lowercase()), "expected all lowercase, got: {v}"); assert!((5..=10).contains(&v.len())); @@ -181,7 +276,7 @@ mod tests { #[test] fn alpha_mixed_only_alpha() { let mut rng = seeded(); - let spec = ParamSpec::AlphaMixed { min_len: 20, max_len: 30, multiple_of: 1, count: CountSpec::default() }; + let spec = ParamSpec::AlphaMixed { min_len: 20, max_len: 30, multiple_of: 1, count: CountSpec::default(), distinct: false, prefix_count: false }; let v = generate_one(&spec, &mut rng); assert!(v.chars().all(|c| c.is_ascii_alphabetic()), "expected only alpha chars, got: {v}"); assert!((20..=30).contains(&v.len())); @@ -190,7 +285,7 @@ mod tests { #[test] fn alpha_mixed_contains_both_cases() { let mut rng = seeded(); - let spec = ParamSpec::AlphaMixed { min_len: 50, max_len: 50, multiple_of: 1, count: CountSpec::default() }; + let spec = ParamSpec::AlphaMixed { min_len: 50, max_len: 50, multiple_of: 1, count: CountSpec::default(), distinct: false, prefix_count: false }; let v = generate_one(&spec, &mut rng); assert!(v.chars().any(|c| c.is_ascii_uppercase()), "expected at least one uppercase"); assert!(v.chars().any(|c| c.is_ascii_lowercase()), "expected at least one lowercase"); @@ -199,7 +294,7 @@ mod tests { #[test] fn printable_ascii_valid_chars() { let mut rng = seeded(); - let spec = ParamSpec::PrintableAscii { min_len: 20, max_len: 30, multiple_of: 1, count: CountSpec::default() }; + let spec = ParamSpec::PrintableAscii { min_len: 20, max_len: 30, multiple_of: 1, count: CountSpec::default(), distinct: false, prefix_count: false }; let v = generate_one(&spec, &mut rng); assert!( v.chars().all(|c| c as u8 >= 0x21 && c as u8 <= 0x7e), @@ -215,6 +310,8 @@ mod tests { min: 1, max: 100, count: CountSpec { min: 3, max: 3, separator: " ".to_string() }, + distinct: false, + prefix_count: false, }; let v = generate_one(&spec, &mut rng); let parts: Vec<&str> = v.split(' ').collect(); @@ -228,7 +325,7 @@ mod tests { #[test] fn count_one_produces_no_spaces_for_int() { let mut rng = seeded(); - let spec = ParamSpec::Int { min: 0, max: 1000, count: CountSpec::default() }; + let spec = ParamSpec::Int { min: 0, max: 1000, count: CountSpec::default(), distinct: false, prefix_count: false }; let v = generate_one(&spec, &mut rng); assert!(!v.contains(' '), "count=1 should produce a single value with no spaces, got: {v}"); } @@ -236,8 +333,8 @@ mod tests { #[test] fn generate_input_joins_in_declaration_order() { let params = make_params(&[ - ("plaintext", ParamSpec::AlphaUpper { min_len: 5, max_len: 5, multiple_of: 1, count: CountSpec::default() }), - ("shift", ParamSpec::Int { min: 3, max: 3, count: CountSpec::default() }), + ("plaintext", ParamSpec::AlphaUpper { min_len: 5, max_len: 5, multiple_of: 1, count: CountSpec::default(), distinct: false, prefix_count: false }), + ("shift", ParamSpec::Int { min: 3, max: 3, count: CountSpec::default(), distinct: false, prefix_count: false }), ]); let mut rng = seeded(); let input = generate_input(¶ms, &mut rng); @@ -249,7 +346,7 @@ mod tests { #[test] fn generate_input_single_param() { - let params = make_params(&[("n", ParamSpec::Int { min: 42, max: 42, count: CountSpec::default() })]); + let params = make_params(&[("n", ParamSpec::Int { min: 42, max: 42, count: CountSpec::default(), distinct: false, prefix_count: false })]); let mut rng = seeded(); let input = generate_input(¶ms, &mut rng); assert_eq!(input, "42"); @@ -258,9 +355,9 @@ mod tests { #[test] fn generate_input_three_params_ordered() { let params = make_params(&[ - ("m", ParamSpec::Int { min: 65, max: 65, count: CountSpec::default() }), - ("e", ParamSpec::Int { min: 17, max: 17, count: CountSpec::default() }), - ("n", ParamSpec::Int { min: 3233, max: 3233, count: CountSpec::default() }), + ("m", ParamSpec::Int { min: 65, max: 65, count: CountSpec::default(), distinct: false, prefix_count: false }), + ("e", ParamSpec::Int { min: 17, max: 17, count: CountSpec::default(), distinct: false, prefix_count: false }), + ("n", ParamSpec::Int { min: 3233, max: 3233, count: CountSpec::default(), distinct: false, prefix_count: false }), ]); let mut rng = seeded(); let input = generate_input(¶ms, &mut rng); @@ -274,6 +371,8 @@ mod tests { min: 5, max: 5, count: CountSpec { min: 4, max: 4, separator: " ".to_string() }, + distinct: false, + prefix_count: false, }; let v = generate_one(&spec, &mut rng); assert_eq!(v, "5 5 5 5"); @@ -286,6 +385,8 @@ mod tests { min: 1, max: 1, count: CountSpec { min: 2, max: 5, separator: " ".to_string() }, + distinct: false, + prefix_count: false, }; // Run many times to confirm count always stays within [2, 5] for seed in 0..200u64 { @@ -307,6 +408,8 @@ mod tests { min: 7, max: 7, count: CountSpec { min: 3, max: 3, separator: ",".to_string() }, + distinct: false, + prefix_count: false, }; let v = generate_one(&spec, &mut rng); assert_eq!(v, "7,7,7", "expected comma-separated values, got: {v}"); @@ -320,6 +423,8 @@ mod tests { min: 3, max: 3, count: CountSpec { min: 2, max: 2, separator: "|".to_string() }, + distinct: false, + prefix_count: false, }; let v = generate_one(&spec, &mut rng); assert_eq!(v, "3|3", "expected pipe-separated values without trailing separator"); @@ -333,6 +438,8 @@ mod tests { let spec = ParamSpec::Enum { values: vec!["ECB".to_string(), "CBC".to_string()], count: CountSpec::default(), + distinct: false, + prefix_count: false, }; for _ in 0..100 { let v = generate_one(&spec, &mut rng); @@ -349,6 +456,8 @@ mod tests { let spec = ParamSpec::Enum { values: vec!["A".to_string(), "B".to_string(), "C".to_string()], count: CountSpec { min: 3, max: 3, separator: ",".to_string() }, + distinct: false, + prefix_count: false, }; let v = generate_one(&spec, &mut rng); let parts: Vec<&str> = v.split(',').collect(); @@ -368,6 +477,8 @@ mod tests { max_len: 64, multiple_of: 16, count: CountSpec::default(), + distinct: false, + prefix_count: false, }; for seed in 0..200u64 { let mut rng = SmallRng::seed_from_u64(seed); @@ -390,6 +501,8 @@ mod tests { max_len: 10, multiple_of: 1, count: CountSpec::default(), + distinct: false, + prefix_count: false, }; for seed in 0..50u64 { let mut rng = SmallRng::seed_from_u64(seed); @@ -403,12 +516,221 @@ mod tests { let spec = ParamSpec::Enum { values: vec!["X".to_string(), "Y".to_string(), "Z".to_string()], count: CountSpec::default(), + distinct: false, + prefix_count: false, }; let v1 = generate_one(&spec, &mut SmallRng::seed_from_u64(7)); let v2 = generate_one(&spec, &mut SmallRng::seed_from_u64(7)); assert_eq!(v1, v2); } + // ── distinct sampling (M1, M7–M9, M18, M20) ────────────────────────────── + + fn parse_one(json: &str) -> ParamSpec { + crate::parser::parse_params(json).unwrap().shift_remove_index(0).unwrap().1 + } + + #[test] + fn distinct_int_values_are_pairwise_distinct() { + // M1: general case, rejection-sampling path (huge domain) + let spec = parse_one( + r#"{"n": {"type": "int", "min": 1, "max": 1000000000, "count": {"min": 5, "max": 20}, "distinct": true}}"#, + ); + for seed in 0..100u64 { + let mut rng = SmallRng::seed_from_u64(seed); + let line = generate_one(&spec, &mut rng); + let parts: Vec = line.split(' ').map(|p| p.parse().unwrap()).collect(); + assert!((5..=20).contains(&parts.len()), "seed={seed}: count out of range: {line}"); + let unique: std::collections::HashSet<_> = parts.iter().collect(); + assert_eq!(unique.len(), parts.len(), "seed={seed}: duplicates in: {line}"); + assert!(parts.iter().all(|v| (1..=1_000_000_000).contains(v)), "seed={seed}: out of range: {line}"); + } + } + + #[test] + fn distinct_tight_domain_is_permutation() { + // M7/M8/M9: domain size == count.min == count.max → random permutation + let spec = parse_one( + r#"{"n": {"type": "int", "min": 1, "max": 5, "count": {"min": 5, "max": 5}, "distinct": true}}"#, + ); + for seed in 0..100u64 { + let mut rng = SmallRng::seed_from_u64(seed); + let line = generate_one(&spec, &mut rng); + let mut parts: Vec = line.split(' ').map(|p| p.parse().unwrap()).collect(); + parts.sort_unstable(); + assert_eq!(parts, vec![1, 2, 3, 4, 5], "seed={seed}: not a permutation: {line}"); + } + } + + #[test] + fn distinct_permutation_order_varies_across_seeds() { + // The permutation must be random, not the sorted domain every time + let spec = parse_one( + r#"{"n": {"type": "int", "min": 1, "max": 5, "count": {"min": 5, "max": 5}, "distinct": true}}"#, + ); + let lines: std::collections::HashSet = (0..50u64) + .map(|seed| generate_one(&spec, &mut SmallRng::seed_from_u64(seed))) + .collect(); + assert!(lines.len() > 1, "all 50 seeds produced the same order"); + } + + #[test] + fn distinct_enum_dedups_then_permutes() { + // M18/M20: duplicated values are deduplicated before sampling + let spec = parse_one( + r#"{"c": {"type": "enum", "values": ["r", "g", "b", "r"], "count": {"min": 3, "max": 3}, "distinct": true}}"#, + ); + for seed in 0..50u64 { + let mut rng = SmallRng::seed_from_u64(seed); + let line = generate_one(&spec, &mut rng); + let mut parts: Vec<&str> = line.split(' ').collect(); + parts.sort_unstable(); + assert_eq!(parts, vec!["b", "g", "r"], "seed={seed}: not a dedup permutation: {line}"); + } + } + + #[test] + fn distinct_expansion_path_boundary() { + // domain size (8) <= 4 × count.max (2) is false → still must be distinct on + // either path; exercises small-domain sampling with n < domain + let spec = parse_one( + r#"{"n": {"type": "int", "min": 1, "max": 8, "count": {"min": 2, "max": 2}, "distinct": true}}"#, + ); + for seed in 0..100u64 { + let mut rng = SmallRng::seed_from_u64(seed); + let line = generate_one(&spec, &mut rng); + let parts: Vec = line.split(' ').map(|p| p.parse().unwrap()).collect(); + assert_eq!(parts.len(), 2); + assert_ne!(parts[0], parts[1], "seed={seed}: duplicate pair: {line}"); + } + } + + // ── prefix_count output format (M2–M6, M10–M12) ────────────────────────── + + #[test] + fn prefix_count_int_default_separator() { + // M2: `n x1 ... xn` with n = actual count + let spec = parse_one( + r#"{"n": {"type": "int", "min": 1, "max": 100, "count": {"min": 3, "max": 3}, "prefix_count": true}}"#, + ); + let mut rng = seeded(); + let line = generate_one(&spec, &mut rng); + let parts: Vec<&str> = line.split(' ').collect(); + assert_eq!(parts.len(), 4, "expected `3 x1 x2 x3`, got: {line}"); + assert_eq!(parts[0], "3", "first token must be the actual count, got: {line}"); + for p in &parts[1..] { + let v: i64 = p.parse().unwrap(); + assert!((1..=100).contains(&v)); + } + } + + #[test] + fn prefix_count_reports_actual_count_not_count_max() { + // n is the drawn count, not count.max + let spec = parse_one( + r#"{"n": {"type": "int", "min": 1, "max": 100, "count": {"min": 2, "max": 8}, "prefix_count": true}}"#, + ); + for seed in 0..100u64 { + let mut rng = SmallRng::seed_from_u64(seed); + let line = generate_one(&spec, &mut rng); + let parts: Vec<&str> = line.split(' ').collect(); + let n: usize = parts[0].parse().unwrap(); + assert!((2..=8).contains(&n), "seed={seed}: prefix out of range: {line}"); + assert_eq!(parts.len(), n + 1, "seed={seed}: prefix must equal value count: {line}"); + } + } + + #[test] + fn prefix_count_string_type() { + // M3: same format with string values + let spec = parse_one( + r#"{"s": {"type": "alpha_upper", "min_len": 2, "max_len": 4, "count": {"min": 2, "max": 2}, "prefix_count": true}}"#, + ); + let mut rng = seeded(); + let line = generate_one(&spec, &mut rng); + let parts: Vec<&str> = line.split(' ').collect(); + assert_eq!(parts.len(), 3, "expected `2 s1 s2`, got: {line}"); + assert_eq!(parts[0], "2"); + for p in &parts[1..] { + assert!(p.chars().all(|c| c.is_ascii_uppercase())); + } + } + + #[test] + fn prefix_count_enum() { + // M4: same format with enum values + let spec = parse_one( + r#"{"c": {"type": "enum", "values": ["r", "g"], "count": {"min": 2, "max": 2}, "prefix_count": true}}"#, + ); + let mut rng = seeded(); + let line = generate_one(&spec, &mut rng); + let parts: Vec<&str> = line.split(' ').collect(); + assert_eq!(parts.len(), 3); + assert_eq!(parts[0], "2"); + for p in &parts[1..] { + assert!(*p == "r" || *p == "g"); + } + } + + #[test] + fn prefix_count_custom_separator_join_semantics() { + // M5: `n,x1,x2` — the prefix shares the separator, none trailing + let spec = parse_one( + r#"{"n": {"type": "int", "min": 7, "max": 7, "count": {"min": 2, "max": 2, "separator": ","}, "prefix_count": true}}"#, + ); + let mut rng = seeded(); + let line = generate_one(&spec, &mut rng); + assert_eq!(line, "2,7,7", "expected join semantics with comma, got: {line}"); + } + + #[test] + fn prefix_count_combined_with_distinct() { + // M6: both features at once + let spec = parse_one( + r#"{"n": {"type": "int", "min": 1, "max": 1000000, "count": {"min": 5, "max": 8}, "distinct": true, "prefix_count": true}}"#, + ); + for seed in 0..50u64 { + let mut rng = SmallRng::seed_from_u64(seed); + let line = generate_one(&spec, &mut rng); + let parts: Vec<&str> = line.split(' ').collect(); + let n: usize = parts[0].parse().unwrap(); + assert!((5..=8).contains(&n), "seed={seed}: {line}"); + assert_eq!(parts.len(), n + 1, "seed={seed}: {line}"); + let unique: std::collections::HashSet<_> = parts[1..].iter().collect(); + assert_eq!(unique.len(), n, "seed={seed}: duplicates: {line}"); + } + } + + #[test] + fn prefix_count_zero_values_outputs_bare_zero() { + // M10: n = 0 → the line is exactly `0`, no separator, no values + let spec = parse_one( + r#"{"n": {"type": "int", "min": 1, "max": 100, "count": {"min": 0, "max": 0}, "prefix_count": true}}"#, + ); + let mut rng = seeded(); + assert_eq!(generate_one(&spec, &mut rng), "0"); + } + + #[test] + fn zero_values_without_prefix_count_outputs_empty_line() { + // M11: existing behavior preserved + let spec = parse_one( + r#"{"n": {"type": "int", "min": 1, "max": 100, "count": {"min": 0, "max": 0}}}"#, + ); + let mut rng = seeded(); + assert_eq!(generate_one(&spec, &mut rng), ""); + } + + #[test] + fn prefix_count_with_count_omitted() { + // M12: omitted count ≡ {min: 1, max: 1, separator: " "} → `1 value` + let spec = parse_one( + r#"{"n": {"type": "int", "min": 42, "max": 42, "prefix_count": true}}"#, + ); + let mut rng = seeded(); + assert_eq!(generate_one(&spec, &mut rng), "1 42"); + } + #[cfg(feature = "faker")] mod faker_tests { use super::*; @@ -420,6 +742,8 @@ mod tests { let spec = ParamSpec::Faker { category: FakerCategory::Name, count: CountSpec::default(), + distinct: false, + prefix_count: false, }; let v = generate_one(&spec, &mut rng); assert!(!v.is_empty(), "faker name should be non-empty"); @@ -431,6 +755,8 @@ mod tests { let spec = ParamSpec::Faker { category: FakerCategory::Email, count: CountSpec { min: 2, max: 2, separator: ",".to_string() }, + distinct: false, + prefix_count: false, }; let v = generate_one(&spec, &mut rng); let parts: Vec<&str> = v.split(',').collect(); @@ -456,6 +782,8 @@ mod tests { let spec = ParamSpec::Faker { category: cat.clone(), count: CountSpec::default(), + distinct: false, + prefix_count: false, }; let v = generate_one(&spec, &mut rng); assert!(!v.is_empty(), "faker {:?} should be non-empty", cat); diff --git a/crates/random-input-generator/tests/conformance.rs b/crates/random-input-generator/tests/conformance.rs new file mode 100644 index 0000000..dba50bf --- /dev/null +++ b/crates/random-input-generator/tests/conformance.rs @@ -0,0 +1,205 @@ +//! Conformance harness: data-driven fixtures shared across implementations. +//! +//! Each fixture in `tests/fixtures/*.json` is one acceptance unit from the +//! requirement's traceability matrix (M1–M22): a `params` spec plus a +//! declarative `expect` block. The params and expectations are language-neutral +//! data so a future second implementation only needs to rewrite this thin +//! harness, not the fixtures. +//! +//! Fixture shape: +//! ```json +//! { +//! "description": "M1: distinct int basic", +//! "params": { "": { ...ParamSpec... } }, // exactly one param +//! "iterations": 100, // optional, default 50 +//! "expect": { +//! "error": true, // construction must fail... +//! "error_contains": "distinct", // ...with this substring (optional) +//! "line": { // or: checks on the generated line +//! "separator": " ", // token separator (default " ") +//! "prefix_count": true, // first token == number of following tokens +//! "distinct": true, // value tokens pairwise distinct +//! "count_range": [5, 20], // number of value tokens (prefix excluded) +//! "int_range": [1, 1000000], // every value token parses into this range +//! "values_in": ["r", "g"], // every value token is from this set +//! "permutation_of": ["1", "2"], // value tokens are exactly this multiset +//! "exact": "0" // the whole line equals this string +//! } +//! } +//! } +//! ``` + +use rand::SeedableRng; +use rand::rngs::SmallRng; +use random_input_generator::generate; +use serde_json::Value; +use std::path::{Path, PathBuf}; + +const DEFAULT_ITERATIONS: u64 = 50; +/// Self-held fixture seed base (Part II contract: each implementation picks and +/// records its own seeds). Changing it is a reviewed test change. +const SEED_BASE: u64 = 0x5EED_BA5E; + +fn fixture_paths() -> Vec { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + let mut paths: Vec = std::fs::read_dir(&dir) + .expect("tests/fixtures directory must exist") + .map(|e| e.expect("readable dir entry").path()) + .filter(|p| p.extension().is_some_and(|e| e == "json")) + .collect(); + paths.sort(); + paths +} + +#[test] +fn conformance_fixtures() { + let paths = fixture_paths(); + assert!(!paths.is_empty(), "no fixtures found in tests/fixtures"); + println!("loaded {} conformance fixtures", paths.len()); + for path in &paths { + let name = path.file_name().unwrap().to_string_lossy().to_string(); + run_fixture(&name, path); + } +} + +fn run_fixture(name: &str, path: &Path) { + let raw = std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("{name}: cannot read fixture: {e}")); + let fixture: Value = serde_json::from_str(&raw) + .unwrap_or_else(|e| panic!("{name}: fixture is not valid JSON: {e}")); + + let params_value = fixture + .get("params") + .unwrap_or_else(|| panic!("{name}: fixture missing `params`")); + let params_obj = params_value + .as_object() + .unwrap_or_else(|| panic!("{name}: `params` must be an object")); + assert_eq!( + params_obj.len(), + 1, + "{name}: harness supports exactly one param per fixture" + ); + let params_json = serde_json::to_string(params_value).unwrap(); + let expect = fixture + .get("expect") + .unwrap_or_else(|| panic!("{name}: fixture missing `expect`")); + + // ── construction-time error expectations ──────────────────────────────── + if expect.get("error").and_then(Value::as_bool) == Some(true) { + let mut rng = SmallRng::seed_from_u64(SEED_BASE); + let err = match generate(¶ms_json, 1, &mut rng) { + Err(e) => e, + Ok(_) => panic!("{name}: expected a construction-time error, but parsing succeeded"), + }; + if let Some(substr) = expect.get("error_contains").and_then(Value::as_str) { + assert!( + err.contains(substr), + "{name}: error should contain '{substr}', got: {err}" + ); + } + return; + } + + // ── generated-line expectations ───────────────────────────────────────── + let line_checks = expect + .get("line") + .unwrap_or_else(|| panic!("{name}: expect needs `error` or `line`")); + let iterations = fixture + .get("iterations") + .and_then(Value::as_u64) + .unwrap_or(DEFAULT_ITERATIONS); + + for i in 0..iterations { + let mut rng = SmallRng::seed_from_u64(SEED_BASE + i); + let lines = generate(¶ms_json, 1, &mut rng) + .unwrap_or_else(|e| panic!("{name}: unexpected parse error: {e}")); + check_line(name, i, &lines[0], line_checks); + } +} + +fn check_line(name: &str, iteration: u64, line: &str, checks: &Value) { + let ctx = format!("{name} (iteration {iteration}, line: {line:?})"); + + if let Some(exact) = checks.get("exact").and_then(Value::as_str) { + assert_eq!(line, exact, "{ctx}: exact mismatch"); + return; + } + + let separator = checks + .get("separator") + .and_then(Value::as_str) + .unwrap_or(" "); + let all_tokens: Vec<&str> = if line.is_empty() { + Vec::new() + } else { + line.split(separator).collect() + }; + + // prefix_count: first token declares how many value tokens follow. + let value_tokens: &[&str] = if checks.get("prefix_count").and_then(Value::as_bool) == Some(true) + { + let prefix = all_tokens + .first() + .unwrap_or_else(|| panic!("{ctx}: expected a prefix token")); + let n: usize = prefix + .parse() + .unwrap_or_else(|_| panic!("{ctx}: prefix token '{prefix}' is not a count")); + assert_eq!( + all_tokens.len(), + n + 1, + "{ctx}: prefix {n} must equal the number of following value tokens" + ); + &all_tokens[1..] + } else { + &all_tokens[..] + }; + + if let Some(range) = checks.get("count_range") { + let lo = range[0].as_u64().unwrap() as usize; + let hi = range[1].as_u64().unwrap() as usize; + assert!( + (lo..=hi).contains(&value_tokens.len()), + "{ctx}: value token count {} outside [{lo}, {hi}]", + value_tokens.len() + ); + } + + if checks.get("distinct").and_then(Value::as_bool) == Some(true) { + let unique: std::collections::HashSet<_> = value_tokens.iter().collect(); + assert_eq!( + unique.len(), + value_tokens.len(), + "{ctx}: value tokens must be pairwise distinct" + ); + } + + if let Some(range) = checks.get("int_range") { + let lo = range[0].as_i64().unwrap(); + let hi = range[1].as_i64().unwrap(); + for t in value_tokens { + let v: i64 = t + .parse() + .unwrap_or_else(|_| panic!("{ctx}: token '{t}' is not an integer")); + assert!( + (lo..=hi).contains(&v), + "{ctx}: value {v} outside [{lo}, {hi}]" + ); + } + } + + if let Some(allowed) = checks.get("values_in").and_then(Value::as_array) { + let set: std::collections::HashSet<&str> = + allowed.iter().filter_map(Value::as_str).collect(); + for t in value_tokens { + assert!(set.contains(t), "{ctx}: token '{t}' not in allowed set"); + } + } + + if let Some(expected) = checks.get("permutation_of").and_then(Value::as_array) { + let mut expected: Vec<&str> = expected.iter().filter_map(Value::as_str).collect(); + let mut actual: Vec<&str> = value_tokens.to_vec(); + expected.sort_unstable(); + actual.sort_unstable(); + assert_eq!(actual, expected, "{ctx}: tokens are not the expected permutation"); + } +} diff --git a/crates/random-input-generator/tests/fixtures/m01_distinct_int_basic.json b/crates/random-input-generator/tests/fixtures/m01_distinct_int_basic.json new file mode 100644 index 0000000..a8ece32 --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m01_distinct_int_basic.json @@ -0,0 +1,29 @@ +{ + "description": "M1: int/distinct — pairwise distinct, count in [5,20], values in [1,1e6], order checked by Q1", + "params": { + "numbers": { + "type": "int", + "min": 1, + "max": 1000000, + "count": { + "min": 5, + "max": 20 + }, + "distinct": true + } + }, + "iterations": 100, + "expect": { + "line": { + "distinct": true, + "count_range": [ + 5, + 20 + ], + "int_range": [ + 1, + 1000000 + ] + } + } +} diff --git a/crates/random-input-generator/tests/fixtures/m02_prefix_int_format.json b/crates/random-input-generator/tests/fixtures/m02_prefix_int_format.json new file mode 100644 index 0000000..715f138 --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m02_prefix_int_format.json @@ -0,0 +1,29 @@ +{ + "description": "M2: int/prefix_count — `n x1 ... xn` with n = actual count", + "params": { + "n": { + "type": "int", + "min": 1, + "max": 100, + "count": { + "min": 2, + "max": 8 + }, + "prefix_count": true + } + }, + "iterations": 100, + "expect": { + "line": { + "prefix_count": true, + "count_range": [ + 2, + 8 + ], + "int_range": [ + 1, + 100 + ] + } + } +} diff --git a/crates/random-input-generator/tests/fixtures/m03_prefix_string_format.json b/crates/random-input-generator/tests/fixtures/m03_prefix_string_format.json new file mode 100644 index 0000000..210ded1 --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m03_prefix_string_format.json @@ -0,0 +1,24 @@ +{ + "description": "M3: alpha_upper/prefix_count — same format with string values", + "params": { + "s": { + "type": "alpha_upper", + "min_len": 2, + "max_len": 4, + "count": { + "min": 2, + "max": 5 + }, + "prefix_count": true + } + }, + "expect": { + "line": { + "prefix_count": true, + "count_range": [ + 2, + 5 + ] + } + } +} diff --git a/crates/random-input-generator/tests/fixtures/m04_prefix_enum_format.json b/crates/random-input-generator/tests/fixtures/m04_prefix_enum_format.json new file mode 100644 index 0000000..833225f --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m04_prefix_enum_format.json @@ -0,0 +1,32 @@ +{ + "description": "M4: enum/prefix_count — same format, values from the list", + "params": { + "c": { + "type": "enum", + "values": [ + "red", + "green", + "blue" + ], + "count": { + "min": 2, + "max": 3 + }, + "prefix_count": true + } + }, + "expect": { + "line": { + "prefix_count": true, + "count_range": [ + 2, + 3 + ], + "values_in": [ + "red", + "green", + "blue" + ] + } + } +} diff --git a/crates/random-input-generator/tests/fixtures/m05_prefix_custom_separator.json b/crates/random-input-generator/tests/fixtures/m05_prefix_custom_separator.json new file mode 100644 index 0000000..7edf24c --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m05_prefix_custom_separator.json @@ -0,0 +1,30 @@ +{ + "description": "M5: prefix_count with non-space separator — `n,x1,...,xn` join semantics", + "params": { + "n": { + "type": "int", + "min": 1, + "max": 100, + "count": { + "min": 3, + "max": 3, + "separator": "," + }, + "prefix_count": true + } + }, + "expect": { + "line": { + "separator": ",", + "prefix_count": true, + "count_range": [ + 3, + 3 + ], + "int_range": [ + 1, + 100 + ] + } + } +} diff --git a/crates/random-input-generator/tests/fixtures/m06_distinct_prefix_combined.json b/crates/random-input-generator/tests/fixtures/m06_distinct_prefix_combined.json new file mode 100644 index 0000000..e5036b4 --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m06_distinct_prefix_combined.json @@ -0,0 +1,31 @@ +{ + "description": "M6: int/distinct+prefix_count — APCS line format with pairwise-distinct values", + "params": { + "n": { + "type": "int", + "min": 1, + "max": 1000000, + "count": { + "min": 5, + "max": 8 + }, + "distinct": true, + "prefix_count": true + } + }, + "iterations": 100, + "expect": { + "line": { + "prefix_count": true, + "distinct": true, + "count_range": [ + 5, + 8 + ], + "int_range": [ + 1, + 1000000 + ] + } + } +} diff --git a/crates/random-input-generator/tests/fixtures/m07_distinct_fixed_count.json b/crates/random-input-generator/tests/fixtures/m07_distinct_fixed_count.json new file mode 100644 index 0000000..26c4f0c --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m07_distinct_fixed_count.json @@ -0,0 +1,28 @@ +{ + "description": "M7: distinct with count.min == count.max — exactly count.max distinct values", + "params": { + "n": { + "type": "int", + "min": 1, + "max": 100, + "count": { + "min": 7, + "max": 7 + }, + "distinct": true + } + }, + "expect": { + "line": { + "distinct": true, + "count_range": [ + 7, + 7 + ], + "int_range": [ + 1, + 100 + ] + } + } +} diff --git a/crates/random-input-generator/tests/fixtures/m08_distinct_tight_domain.json b/crates/random-input-generator/tests/fixtures/m08_distinct_tight_domain.json new file mode 100644 index 0000000..f2a69bb --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m08_distinct_tight_domain.json @@ -0,0 +1,28 @@ +{ + "description": "M8: domain size == count.max — output is a random permutation of the domain", + "params": { + "n": { + "type": "int", + "min": 1, + "max": 5, + "count": { + "min": 1, + "max": 5 + }, + "distinct": true + } + }, + "expect": { + "line": { + "distinct": true, + "count_range": [ + 1, + 5 + ], + "int_range": [ + 1, + 5 + ] + } + } +} diff --git a/crates/random-input-generator/tests/fixtures/m09_distinct_triple_tight.json b/crates/random-input-generator/tests/fixtures/m09_distinct_triple_tight.json new file mode 100644 index 0000000..43d197e --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m09_distinct_triple_tight.json @@ -0,0 +1,26 @@ +{ + "description": "M9: domain size == count.min == count.max — permutation, shuffle path mandatory", + "params": { + "n": { + "type": "int", + "min": 1, + "max": 5, + "count": { + "min": 5, + "max": 5 + }, + "distinct": true + } + }, + "expect": { + "line": { + "permutation_of": [ + "1", + "2", + "3", + "4", + "5" + ] + } + } +} diff --git a/crates/random-input-generator/tests/fixtures/m10_prefix_zero_values.json b/crates/random-input-generator/tests/fixtures/m10_prefix_zero_values.json new file mode 100644 index 0000000..34315df --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m10_prefix_zero_values.json @@ -0,0 +1,20 @@ +{ + "description": "M10: n = 0 with prefix_count — line is exactly `0`", + "params": { + "n": { + "type": "int", + "min": 1, + "max": 100, + "count": { + "min": 0, + "max": 0 + }, + "prefix_count": true + } + }, + "expect": { + "line": { + "exact": "0" + } + } +} diff --git a/crates/random-input-generator/tests/fixtures/m11_zero_values_no_prefix.json b/crates/random-input-generator/tests/fixtures/m11_zero_values_no_prefix.json new file mode 100644 index 0000000..225cd60 --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m11_zero_values_no_prefix.json @@ -0,0 +1,19 @@ +{ + "description": "M11: n = 0 without prefix_count — empty line (existing behavior)", + "params": { + "n": { + "type": "int", + "min": 1, + "max": 100, + "count": { + "min": 0, + "max": 0 + } + } + }, + "expect": { + "line": { + "exact": "" + } + } +} diff --git a/crates/random-input-generator/tests/fixtures/m12_prefix_count_omitted.json b/crates/random-input-generator/tests/fixtures/m12_prefix_count_omitted.json new file mode 100644 index 0000000..dc14e6f --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m12_prefix_count_omitted.json @@ -0,0 +1,16 @@ +{ + "description": "M12: prefix_count with count omitted \u2014 `1 value` (count omitted \u2261 min=max=1)", + "params": { + "n": { + "type": "int", + "min": 42, + "max": 42, + "prefix_count": true + } + }, + "expect": { + "line": { + "exact": "1 42" + } + } +} \ No newline at end of file diff --git a/crates/random-input-generator/tests/fixtures/m13_distinct_domain_too_small.json b/crates/random-input-generator/tests/fixtures/m13_distinct_domain_too_small.json new file mode 100644 index 0000000..e5be51e --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m13_distinct_domain_too_small.json @@ -0,0 +1,19 @@ +{ + "description": "M13: domain size < count.max with distinct — construction-time error", + "params": { + "n": { + "type": "int", + "min": 1, + "max": 3, + "count": { + "min": 5, + "max": 5 + }, + "distinct": true + } + }, + "expect": { + "error": true, + "error_contains": "distinct" + } +} diff --git a/crates/random-input-generator/tests/fixtures/m14_min_greater_than_max.json b/crates/random-input-generator/tests/fixtures/m14_min_greater_than_max.json new file mode 100644 index 0000000..ada6d1c --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m14_min_greater_than_max.json @@ -0,0 +1,13 @@ +{ + "description": "M14: min > max — construction-time error (release config too)", + "params": { + "n": { + "type": "int", + "min": 100, + "max": 10 + } + }, + "expect": { + "error": true + } +} diff --git a/crates/random-input-generator/tests/fixtures/m15_count_min_greater_than_max.json b/crates/random-input-generator/tests/fixtures/m15_count_min_greater_than_max.json new file mode 100644 index 0000000..ad9e86d --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m15_count_min_greater_than_max.json @@ -0,0 +1,17 @@ +{ + "description": "M15: count.min > count.max — construction-time error (release config too)", + "params": { + "n": { + "type": "int", + "min": 1, + "max": 10, + "count": { + "min": 5, + "max": 2 + } + } + }, + "expect": { + "error": true + } +} diff --git a/crates/random-input-generator/tests/fixtures/m16_distinct_string_type.json b/crates/random-input-generator/tests/fixtures/m16_distinct_string_type.json new file mode 100644 index 0000000..84c7cf6 --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m16_distinct_string_type.json @@ -0,0 +1,15 @@ +{ + "description": "M16: string type with distinct — construction-time error (unsupported type)", + "params": { + "s": { + "type": "alpha_upper", + "min_len": 1, + "max_len": 5, + "distinct": true + } + }, + "expect": { + "error": true, + "error_contains": "distinct" + } +} diff --git a/crates/random-input-generator/tests/fixtures/m17_distinct_string_with_prefix.json b/crates/random-input-generator/tests/fixtures/m17_distinct_string_with_prefix.json new file mode 100644 index 0000000..7ecb157 --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m17_distinct_string_with_prefix.json @@ -0,0 +1,16 @@ +{ + "description": "M17: string type with distinct+prefix_count — prefix_count does not relax the restriction", + "params": { + "s": { + "type": "hex_string", + "min_len": 1, + "max_len": 5, + "distinct": true, + "prefix_count": true + } + }, + "expect": { + "error": true, + "error_contains": "distinct" + } +} diff --git a/crates/random-input-generator/tests/fixtures/m18_distinct_enum_basic.json b/crates/random-input-generator/tests/fixtures/m18_distinct_enum_basic.json new file mode 100644 index 0000000..45a59e3 --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m18_distinct_enum_basic.json @@ -0,0 +1,36 @@ +{ + "description": "M18: enum/distinct — pairwise distinct, values from list, count correct", + "params": { + "c": { + "type": "enum", + "values": [ + "a", + "b", + "c", + "d", + "e" + ], + "count": { + "min": 2, + "max": 4 + }, + "distinct": true + } + }, + "expect": { + "line": { + "distinct": true, + "count_range": [ + 2, + 4 + ], + "values_in": [ + "a", + "b", + "c", + "d", + "e" + ] + } + } +} diff --git a/crates/random-input-generator/tests/fixtures/m19_distinct_enum_prefix.json b/crates/random-input-generator/tests/fixtures/m19_distinct_enum_prefix.json new file mode 100644 index 0000000..e3fa4e9 --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m19_distinct_enum_prefix.json @@ -0,0 +1,36 @@ +{ + "description": "M19: enum/distinct+prefix_count — M18 plus prefix format", + "params": { + "c": { + "type": "enum", + "values": [ + "a", + "b", + "c", + "d" + ], + "count": { + "min": 2, + "max": 3 + }, + "distinct": true, + "prefix_count": true + } + }, + "expect": { + "line": { + "prefix_count": true, + "distinct": true, + "count_range": [ + 2, + 3 + ], + "values_in": [ + "a", + "b", + "c", + "d" + ] + } + } +} diff --git a/crates/random-input-generator/tests/fixtures/m20_distinct_enum_tight.json b/crates/random-input-generator/tests/fixtures/m20_distinct_enum_tight.json new file mode 100644 index 0000000..25c3d40 --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m20_distinct_enum_tight.json @@ -0,0 +1,28 @@ +{ + "description": "M20: deduplicated values count == count.max — permutation of the deduplicated list", + "params": { + "c": { + "type": "enum", + "values": [ + "x", + "y", + "x", + "z" + ], + "count": { + "min": 3, + "max": 3 + }, + "distinct": true + } + }, + "expect": { + "line": { + "permutation_of": [ + "x", + "y", + "z" + ] + } + } +} diff --git a/crates/random-input-generator/tests/fixtures/m21_distinct_enum_too_small.json b/crates/random-input-generator/tests/fixtures/m21_distinct_enum_too_small.json new file mode 100644 index 0000000..52a3aab --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m21_distinct_enum_too_small.json @@ -0,0 +1,22 @@ +{ + "description": "M21: deduplicated values count < count.max — construction-time error", + "params": { + "c": { + "type": "enum", + "values": [ + "a", + "b", + "a" + ], + "count": { + "min": 3, + "max": 3 + }, + "distinct": true + } + }, + "expect": { + "error": true, + "error_contains": "distinct" + } +} diff --git a/crates/random-input-generator/tests/fixtures/m22a_backward_compat_int.json b/crates/random-input-generator/tests/fixtures/m22a_backward_compat_int.json new file mode 100644 index 0000000..73be6d1 --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m22a_backward_compat_int.json @@ -0,0 +1,22 @@ +{ + "description": "M22: existing int params without new fields — semantics unchanged", + "params": { + "shift": { + "type": "int", + "min": 1, + "max": 25 + } + }, + "expect": { + "line": { + "count_range": [ + 1, + 1 + ], + "int_range": [ + 1, + 25 + ] + } + } +} diff --git a/crates/random-input-generator/tests/fixtures/m22b_backward_compat_count.json b/crates/random-input-generator/tests/fixtures/m22b_backward_compat_count.json new file mode 100644 index 0000000..76e9452 --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m22b_backward_compat_count.json @@ -0,0 +1,26 @@ +{ + "description": "M22: existing count params without new fields — space-joined, no prefix", + "params": { + "n": { + "type": "int", + "min": 1, + "max": 100, + "count": { + "min": 3, + "max": 3 + } + } + }, + "expect": { + "line": { + "count_range": [ + 3, + 3 + ], + "int_range": [ + 1, + 100 + ] + } + } +} diff --git a/crates/random-input-generator/tests/fixtures/m22c_backward_compat_enum.json b/crates/random-input-generator/tests/fixtures/m22c_backward_compat_enum.json new file mode 100644 index 0000000..1880b25 --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m22c_backward_compat_enum.json @@ -0,0 +1,24 @@ +{ + "description": "M22: existing enum params without new fields — values from list, single token", + "params": { + "mode": { + "type": "enum", + "values": [ + "ECB", + "CBC" + ] + } + }, + "expect": { + "line": { + "count_range": [ + 1, + 1 + ], + "values_in": [ + "ECB", + "CBC" + ] + } + } +} diff --git a/crates/random-input-generator/tests/fixtures/m23a_unknown_field_rejected.json b/crates/random-input-generator/tests/fixtures/m23a_unknown_field_rejected.json new file mode 100644 index 0000000..80f0b3e --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m23a_unknown_field_rejected.json @@ -0,0 +1,15 @@ +{ + "description": "Hardening: misspelled prefix_count key must fail loudly, not silently disable the guarantee", + "params": { + "n": { + "type": "int", + "min": 1, + "max": 10, + "prefix-count": true + } + }, + "expect": { + "error": true, + "error_contains": "unknown field" + } +} diff --git a/crates/random-input-generator/tests/fixtures/m23b_unknown_nested_field_rejected.json b/crates/random-input-generator/tests/fixtures/m23b_unknown_nested_field_rejected.json new file mode 100644 index 0000000..4c47057 --- /dev/null +++ b/crates/random-input-generator/tests/fixtures/m23b_unknown_nested_field_rejected.json @@ -0,0 +1,19 @@ +{ + "description": "Hardening: misspelled count.separator key must fail loudly", + "params": { + "n": { + "type": "int", + "min": 1, + "max": 10, + "count": { + "min": 2, + "max": 3, + "seperator": "," + } + } + }, + "expect": { + "error": true, + "error_contains": "unknown field" + } +} diff --git a/crates/random-input-generator/tests/quality.rs b/crates/random-input-generator/tests/quality.rs new file mode 100644 index 0000000..5519a7f --- /dev/null +++ b/crates/random-input-generator/tests/quality.rs @@ -0,0 +1,138 @@ +//! Advisory quality checks (requirement Part II: Q1 order, Q2 uniformity, +//! Q3 performance). These are implementation-local smoke tests; the RNG seeds +//! are self-held by this implementation (Part II contract) and changing them +//! is a reviewed test change. + +use rand::SeedableRng; +use rand::rngs::SmallRng; +use random_input_generator::generate; + +/// Self-held seed for the statistical smoke tests. +const QUALITY_SEED: u64 = 20260726; + +fn lines_for(params_json: &str, n_lines: usize, rng: &mut SmallRng) -> Vec { + generate(params_json, n_lines, rng).expect("valid params") +} + +fn fully_sorted_counts(lines: &[String]) -> (usize, usize) { + let mut ascending = 0; + let mut descending = 0; + for line in lines { + let values: Vec = line.split(' ').map(|t| t.parse().unwrap()).collect(); + if values.windows(2).all(|w| w[0] < w[1]) { + ascending += 1; + } + if values.windows(2).all(|w| w[0] > w[1]) { + descending += 1; + } + } + (ascending, descending) +} + +/// Q1 (primary): n = 10, domain [1, 10^6], 1000 lines with a fixed seed. +/// Fully ascending / fully descending lines must each stay <= 10 — the random +/// expectation is ~0.0003 lines, so the threshold only catches systematic +/// sorting leaked from the sampling implementation. +#[test] +fn q1_output_order_not_systematically_sorted_primary() { + let mut rng = SmallRng::seed_from_u64(QUALITY_SEED); + let lines = lines_for( + r#"{"n": {"type": "int", "min": 1, "max": 1000000, "count": {"min": 10, "max": 10}, "distinct": true}}"#, + 1000, + &mut rng, + ); + let (asc, desc) = fully_sorted_counts(&lines); + assert!(asc <= 10, "systematic ascending order: {asc}/1000 lines fully ascending"); + assert!(desc <= 10, "systematic descending order: {desc}/1000 lines fully descending"); +} + +/// Q1 (secondary): n = 20, domain [1, 100] — guards against "only unsorted for +/// certain parameters" (this hits the materialise-and-shuffle path). +#[test] +fn q1_output_order_not_systematically_sorted_secondary() { + let mut rng = SmallRng::seed_from_u64(QUALITY_SEED + 1); + let lines = lines_for( + r#"{"n": {"type": "int", "min": 1, "max": 100, "count": {"min": 20, "max": 20}, "distinct": true}}"#, + 1000, + &mut rng, + ); + let (asc, desc) = fully_sorted_counts(&lines); + assert!(asc <= 10, "systematic ascending order: {asc}/1000 lines fully ascending"); + assert!(desc <= 10, "systematic descending order: {desc}/1000 lines fully descending"); +} + +/// Q2: value-selection uniformity. min=1, max=20, count 10, fixed seed, 10^4 +/// lines. Chi-squared over the 20 per-value totals: E = 5000, df = 19, +/// alpha = 0.001 → critical value 43.82. Without-replacement sampling makes the +/// counts negatively correlated (variance below the independent multinomial +/// model), so the standard critical value is conservative — no correction. +#[test] +fn q2_value_selection_uniformity_chi_squared() { + let mut rng = SmallRng::seed_from_u64(QUALITY_SEED + 2); + let lines = lines_for( + r#"{"n": {"type": "int", "min": 1, "max": 20, "count": {"min": 10, "max": 10}, "distinct": true}}"#, + 10_000, + &mut rng, + ); + let mut observed = [0u64; 20]; + for line in &lines { + for t in line.split(' ') { + let v: usize = t.parse().unwrap(); + observed[v - 1] += 1; + } + } + let expected = 5000.0_f64; + let chi_squared: f64 = observed + .iter() + .map(|&o| { + let d = o as f64 - expected; + d * d / expected + }) + .sum(); + assert!( + chi_squared < 43.82, + "chi-squared {chi_squared:.2} >= 43.82 (df=19, alpha=0.001): selection is not uniform" + ); +} + +/// Q3: performance reference (measured on native release builds — the +/// documented reference environment for this implementation). Debug builds +/// skip: unoptimised timings would only produce false alarms. +#[cfg(not(debug_assertions))] +mod q3_performance { + use super::*; + use std::time::Instant; + + fn assert_single_line_under_100ms(params_json: &str, label: &str) { + let mut rng = SmallRng::seed_from_u64(QUALITY_SEED + 3); + // Warm-up draw, then measure a single line (parse cost included — + // negligible against 10^4 samples, and conservative for the budget). + let _ = generate(params_json, 1, &mut rng).expect("valid params"); + let start = Instant::now(); + let line = &generate(params_json, 1, &mut rng).expect("valid params")[0]; + let elapsed = start.elapsed(); + assert!(!line.is_empty()); + assert!( + elapsed.as_millis() < 100, + "{label}: single line took {elapsed:?} (budget 100 ms)" + ); + } + + /// Q3a: count.max = 10^4 from a huge domain [1, 10^9] (rejection path). + #[test] + fn q3_huge_domain_single_line_under_100ms() { + assert_single_line_under_100ms( + r#"{"n": {"type": "int", "min": 1, "max": 1000000000, "count": {"min": 10000, "max": 10000}, "distinct": true}}"#, + "huge domain [1, 1e9], n = 1e4", + ); + } + + /// Q3b: domain [1, 10^4] fully taken (permutation / shuffle path). + #[test] + fn q3_full_domain_permutation_under_100ms() { + assert_single_line_under_100ms( + r#"{"n": {"type": "int", "min": 1, "max": 10000, "count": {"min": 10000, "max": 10000}, "distinct": true}}"#, + "permutation of [1, 1e4]", + ); + } +} diff --git a/openspec/changes/add-distinct-prefix-count/.openspec.yaml b/openspec/changes/add-distinct-prefix-count/.openspec.yaml new file mode 100644 index 0000000..924f273 --- /dev/null +++ b/openspec/changes/add-distinct-prefix-count/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +created: 2026-07-26 +created_by: CXPhoenix <0826@fhsh.tp.edu.tw> +created_with: claude diff --git a/openspec/changes/add-distinct-prefix-count/design.md b/openspec/changes/add-distinct-prefix-count/design.md new file mode 100644 index 0000000..b35cdf1 --- /dev/null +++ b/openspec/changes/add-distinct-prefix-count/design.md @@ -0,0 +1,74 @@ +## Context + +`crates/random-input-generator` 是 fhsh.py-dojo `testcase-generator` 的抽出版,經 WASM 提供給 VitePress 前端生成隨機測資輸入。現行 `count` 抽樣為獨立抽選(無相異保證),輸出行首也無法自動宣告個數。需求規格 `.spectra/requirements/testcase-generator-distinct-prefix-count.md`(v4.1)已經四輪 adversarial review 收斂,本 change 依該規格落地 R1(`distinct`)與 R2(`prefix_count`)。目前僅有 Rust 單一實作,需求 I.7 多實作驗收條件不成立、不適用。 + +## Goals / Non-Goals + +**Goals:** + +- 依需求 Part I 規範實作 `distinct` 與 `prefix_count` 兩個參數規格頂層欄位,涵蓋追溯矩陣 M1–M22 全部行為。 +- conformance fixtures 以「JSON 資料檔 + Rust harness」形態落地,為未來多實作共用預留。 +- 採用需求 Part II 的 Q1 順序、Q2 均勻性檢查(固定 seed);Q3 效能降級為 native release 測試並標明環境。 +- CI 增加 release 組態測試,消除 `debug_assert!` 假安全。 + +**Non-Goals:** + +- 不動 JS 套件程式碼(params 為 JSON pass-through),僅補文件。 +- 不實作 R3(參數連動/可變行數/dataset 複合型別)與「釘死第 k 筆測資」;僅確保 schema 不堵 R3 的路。 +- 不建 WASM + Node 的效能量測環境。 +- 不處理 fhsh.py-dojo 回灌與多實作 parity(I.7 條件不成立)。 + +## Decisions + +### 欄位命名採 distinct 與 prefix_count + +與需求文件 schema 附錄、追溯矩陣、驗收條款完全一致,零改寫成本。`distinct` 為 SQL/數學標準用語,不會被誤解為全域唯一;`prefix_count` 明確描述位置語意。替代案 `unique` / `with_count` 因需整份需求文件同步改寫且語意較模糊而否決。兩欄位為參數規格頂層欄位(與 `type`、`count` 同層),serde 預設 `false`。 + +### enum 支援 distinct 而字串型別與 faker 建構期報錯 + +`enum` distinct 本質與 `int` 相同(有限集合不放回抽樣),實作成本低、教學有用,且單一實作下無多實作同步負擔,故支援(需求 AC-C3 生效;values 先去重再驗證與抽樣)。字串型別依需求初版不支援、宣告即建構期報錯。`faker` 值域大小無法定義,`faker + distinct` 建構期報錯(比照字串型別,明確拒絕不默默忽略);`faker + prefix_count` 支援(與型別無關)。 + +### 不放回抽樣採門檻式混合策略 + +- 值域大小 ≤ 4 × count.max:將值域展開為陣列,partial Fisher–Yates 洗牌取前 n 個。最多展開 4 × 10^4 個元素(MAX_COUNT = 10^4),緊繃情境(值域大小 = count.max,排列)一次到位。 +- 值域大小 > 4 × count.max:rejection sampling + HashSet 去重,單次碰撞機率 < 1/4,期望重抽次數有上界,巨大值域(如 [1, 10^9] 取 10^4 個)不需展開。 +- 兩路徑輸出順序天然隨機,滿足「不得固定排序」規範,無需額外洗牌。門檻常數實作時可微調,但兩種極端行為不變。 +- 值域大小一律以飽和運算(`i64::saturating_sub` 後轉 `u64`/`u128`)計算,避免 `max − min + 1` 溢位。 + +### conformance fixtures 為 JSON 資料檔加 Rust harness + +每個 fixture 為一個 JSON 檔:params 設定 + 宣告式期望(期望類型涵蓋:distinct 驗證、prefix_count 格式驗證、值域驗證、建構期錯誤、向後相容輸出語意、排列驗證)。單一 Rust 測試 harness 逐檔載入執行。params 與期望為語言中立資料,未來第二實作只需重寫薄 harness,符合需求「fixture = params + check」的共用定義。完整 check DSL 因 YAGNI 否決;純 Rust 測試因堵死共用路徑否決。 + +### CI 增加 release 組態測試並移除既有 debug_assert + +需求 AC-C1-5 要求錯誤路徑 fixtures 於 release 組態產物上完整執行(防 `debug_assert!` 在 release 被 strip)。做法:CI 對 crate 同時跑 debug 與 `cargo test --release` 兩種組態的同一套測試;`debug_assert!` 被 strip 為 Rust 編譯層行為,native release 測試即可暴露,不需套 WASM 層。同時將 rng 模組內既有的兩處 `debug_assert!`(count 範圍、長度範圍)改為依賴 parse 層保證的處理方式,消除地雷。 + +### 品質檢查採 Q1 Q2 全量與 Q3 降級 + +Q1(輸出順序 smoke test:固定 seed 1000 行,完全升冪/降冪各 ≤ 10 行,兩組參數)與 Q2(均勻性卡方檢定:10^4 行、df=19、臨界值 43.82)依需求參數照做,寫成固定 seed 的一般測試。Q3 效能寫成 native release 組態測試(單行 < 100 ms,兩種極端參數組),量測環境於 README 標明為 native release(需求允許環境隨實作標明);WASM 環境數據待回灌時再量。seed 由本實作自選自持,變更走一般 review 流程。 + +## Implementation Contract + +- **行為**:呼叫端傳入含 `distinct` / `prefix_count` 欄位的 params JSON 後,`generate_challenge` 輸出滿足——`distinct: true` 時同行值兩兩相異且順序非固定排序;`prefix_count: true` 時行首多一個 token 為實際個數 n,整行以 `count.separator` join;`n = 0` 且 `prefix_count: true` 時該行輸出恰為 `0`,未開 `prefix_count` 時為空行;兩欄位未宣告時輸出語意與現行位元級一致。 +- **介面/資料形狀**:`CountSpec` 不變;`ParamSpec` 各變體(Int、五種字串型別、Enum、Faker)增加 `distinct: bool` 與 `prefix_count: bool`(serde default false)。公開 API `generate_challenge(params_json, count)` 簽名不變。 +- **失敗模式**:以下組合於 `parse_params` 回傳描述性 `Err`(不 panic、不默默忽略)——`distinct` + 字串型別、`distinct` + faker、`distinct` 且值域大小 < count.max、`min > max`、`count.min > count.max`、enum values 去重後個數 < count.max(宣告 distinct 時)。錯誤訊息含 param 名稱與原因。 +- **驗收方式**:`cargo test`(debug)與 `cargo test --release` 全綠;conformance harness 覆蓋 M1–M22 對應 fixtures;品質測試 Q1/Q2/Q3 通過;`spectra validate` 通過。 +- **範圍邊界**:只動 `crates/random-input-generator`(src、tests、README、Cargo.toml 如需)與 CI workflow 測試步驟;JS packages 程式碼與 examples 不在範圍。 + +## Risks / Trade-offs + +- [rejection sampling 在門檻邊界附近效能抖動] → 門檻 4 × count.max 保證碰撞機率 < 1/4,期望重試次數 < 4/3 倍;Q3 效能測試守住 100 ms 上限。 +- [`i64` 全值域(如 [i64::MIN, i64::MAX])值域大小超過 u64] → 以 u128 或飽和語意計算值域大小;值域大小只需與 count.max(≤ 10^4)比較,飽和到上限即可判定「足夠大」。 +- [固定 seed 統計測試在演算法變更時可能翻紅] → seed 與門檻自持並記錄於測試註解,變更時依 Part II 條款由 review 把關。 +- [fixtures 宣告式期望類型設計過窄,未來 fixture 表達不了新行為] → 期望類型以 M1–M22 全矩陣驗證過再定案;新增期望類型屬向後相容擴充。 +- [release 測試使 CI 時間變長] → 只對本 crate 跑 release 測試,不擴及整個 workspace。 + +## Round 1 硬化決策(audit + 三方 adversarial review 後新增) + +### 未知欄位建構期拒絕 + +`ParamSpec` 與 `CountSpec` 同時加上 serde 的 deny_unknown_fields(實測缺一不可)。動機:拼錯的 opt-in 欄位("distnct"、"prefix-count")原本被靜默忽略、預設 false,等於靜默停用保證並跳過 validate_distinct_domain 守門——對教學 judge 是「學生被錯判且不可重現」的最壞失效模式。相容性依 M22 窄讀(I.2 schema 附錄為規範性,帶未知鍵的 params 非 conforming params)與 baseline spec「SHALL NOT fail silently」條款判定為有意收緊。已知限制:deny_unknown_fields 與 serde flatten 互斥,若 R3(dataset 複合型別)需要 flatten,此保證須另行提供;若未來出現第二實作,I.7 AC-M1 要求欄位集合跨實作一致,第二實作必須同樣嚴格。 + +### 窄公開 API generate 取代 pub mod + +`parser` / `rng` 模組維持私有,改提供 `generate(params_json, count, rng) -> Result, String>` 作為唯一 native 入口(整合測試與未來 native host 共用),並對 `count` 參數加上限(10^4,與 MAX_COUNT 同階)回傳錯誤。動機:pub mod 使呼叫端可繞過 parse_params 手工建構 spec,實測觸發 4 個 panic 點與無上界配置;窄 API 使 parse → generate 不可分離,rng 模組內的 unreachable 斷言回復為真正不可達。同時把 rng 內部 8 臂 match 的 positional tuple 改為具名 struct 存取器,使 distinct / prefix_count 欄位互換成為編譯錯誤。否決的替代案:恢復 debug_assert!(需求 I.3 點名的反例)、generate_input 回傳 Result(在抽樣邊界重跑驗證製造第二真相來源,與「建構期報錯」的落點矛盾)、separator 建構期拒絕(破 M22/AC-C2 且把 I.8 明文要求不要寫死的「一 param 一行」假設寫進 validator,改為 README 文件註記)。 diff --git a/openspec/changes/add-distinct-prefix-count/proposal.md b/openspec/changes/add-distinct-prefix-count/proposal.md new file mode 100644 index 0000000..56e1cc9 --- /dev/null +++ b/openspec/changes/add-distinct-prefix-count/proposal.md @@ -0,0 +1,35 @@ +## Why + +資料結構系列示範題(deque / stack 模擬)需要「一行 n 個相異數字」與 APCS 慣例的「行首宣告個數」輸入格式;現行 `random-input-generator` 的 `count` 抽樣既無相異保證、也無法在行首自動輸出個數,導致「答案依賴元素相異性」的題目可能誤判學生為 WA(公平性問題)。需求規格已於 `.spectra/requirements/testcase-generator-distinct-prefix-count.md`(v4.1)經四輪 adversarial review 收斂,現落地實作。 + +## What Changes + +- 參數規格(ParamSpec)新增兩個頂層欄位,預設皆為 `false`、未宣告時行為與現行完全一致(向後相容): + - `distinct: true` — 同一 param 同一行(同一 count 批次)內的值兩兩相異。`int` 必須支援;`enum` 支援(values 先去重);字串型別與 `faker` 宣告即於建構期報錯。 + - `prefix_count: true` — 將「實際個數 n」與抽出的 n 個值視為同一 token 序列以 `count.separator` join;適用所有型別(含 `faker`);`n = 0` 時輸出僅為 `0`。 +- 建構期驗證擴充:`distinct` 宣告時以不溢位方式(飽和運算)驗證「值域大小 ≥ count.max」,失敗即報錯,不得默默生出重複值或無限迴圈。 +- 不放回抽樣採門檻式混合策略:值域小(≤ 4 × count.max)走展開 + partial Fisher–Yates;值域大走 rejection sampling + HashSet。輸出順序不得為固定排序。 +- conformance fixtures 改為「JSON 資料檔 + Rust harness」形態(宣告式期望類型),對應需求追溯矩陣 M1–M22,為未來多實作共用預留。 +- 品質檢查:新增輸出順序 smoke test(Q1)、選值均勻性卡方檢定(Q2)、效能參考測試(Q3,native release 環境並於文件標明)。 +- CI 新增 `cargo test --release` 步驟(防 `debug_assert!` 在 release 被 strip 的假安全);一併移除 `rng.rs` 既有 `debug_assert!` 地雷。 +- 文件:crate README 補齊 `distinct` / `prefix_count` 欄位規格與全部邊界行為(release checklist 項)。 + +## Capabilities + +### New Capabilities + +(無) + +### Modified Capabilities + +- `random-input-generator`: 參數規格新增 `distinct` 與 `prefix_count` 頂層欄位,含逐型別支援決議、建構期驗證、輸出格式與邊界行為(追溯矩陣 M1–M22)。 + +## Impact + +- Affected specs: `random-input-generator`(修改) +- Affected code: + - New: crates/random-input-generator/tests/conformance.rs、crates/random-input-generator/tests/fixtures/(JSON fixtures 目錄)、crates/random-input-generator/tests/quality.rs + - Modified: crates/random-input-generator/src/parser.rs、crates/random-input-generator/src/rng.rs、crates/random-input-generator/README.md、.github/workflows/(CI 測試步驟) + - Removed: (無) +- 相依套件:不新增外部 crate(HashSet 用標準函式庫) +- npm 套件:實作完成後由 changesets 出 minor 版;JS 端程式碼不動 diff --git a/openspec/changes/add-distinct-prefix-count/specs/random-input-generator/spec.md b/openspec/changes/add-distinct-prefix-count/specs/random-input-generator/spec.md new file mode 100644 index 0000000..c187436 --- /dev/null +++ b/openspec/changes/add-distinct-prefix-count/specs/random-input-generator/spec.md @@ -0,0 +1,94 @@ +## ADDED Requirements + +### Requirement: Distinct values within a line +The generator SHALL accept an optional boolean field `distinct` at the top level of a parameter specification (sibling of `type` and `count`), defaulting to `false`. When `distinct` is `true`, all values generated for that parameter within a single line (one `count` batch) SHALL be pairwise distinct. Distinctness across different parameters is NOT guaranteed. Support is per-type: `int` and `enum` SHALL be supported; string types (`alpha_upper`, `alpha_lower`, `alpha_mixed`, `hex_string`, `printable_ascii`) and `faker` SHALL be rejected with a construction-time error when `distinct: true` is declared. For `enum`, the value domain SHALL be the deduplicated `values` list. The output order of the distinct values SHALL NOT be a fixed sorted order imposed by the implementation. + +#### Scenario: Distinct integers within one line +- **WHEN** a parameter is `{"type": "int", "min": 1, "max": 1000000, "count": {"min": 5, "max": 20}, "distinct": true}` and a line is generated +- **THEN** the line SHALL contain between 5 and 20 values, each in [1, 1000000], all pairwise distinct + +#### Scenario: Tight domain produces a permutation +- **WHEN** a parameter is `{"type": "int", "min": 1, "max": 5, "count": {"min": 5, "max": 5}, "distinct": true}` and a line is generated +- **THEN** the line SHALL be a random permutation of the domain {1, 2, 3, 4, 5} + +##### Example: permutation of a tight domain +- **GIVEN** `{"type": "int", "min": 1, "max": 5, "count": {"min": 5, "max": 5}, "distinct": true}` +- **WHEN** one line is generated +- **THEN** the line contains exactly the values 1, 2, 3, 4, 5 in an order chosen at random (e.g. `3 1 5 2 4`) + +#### Scenario: Distinct enum values +- **WHEN** a parameter is `{"type": "enum", "values": ["red", "green", "blue", "red"], "count": {"min": 3, "max": 3}, "distinct": true}` and a line is generated +- **THEN** the line SHALL be a random permutation of the deduplicated values {red, green, blue} + +#### Scenario: Distinct declared on an unsupported type +- **WHEN** a parameter of a string type or `faker` type declares `distinct: true` +- **THEN** parsing SHALL fail with a construction-time error naming the parameter; the generator SHALL NOT silently ignore the field + +### Requirement: Prefix count line format +The generator SHALL accept an optional boolean field `prefix_count` at the top level of a parameter specification, defaulting to `false`. When `prefix_count` is `true`, the line SHALL be the token sequence consisting of the actual generated count `n` followed by the `n` generated values, joined by `count.separator` (join semantics: the separator appears only between tokens). `n` SHALL be the actual number of values drawn from [count.min, count.max], not `count.max`. `prefix_count` SHALL apply to all parameter types. When `n = 0`, the line SHALL be exactly `0` with no trailing separator and no values. `prefix_count` SHALL NOT relax the per-type support rules of `distinct`. + +#### Scenario: Prefix count with default separator +- **WHEN** a parameter is `{"type": "int", "min": 1, "max": 100, "count": {"min": 3, "max": 3}, "prefix_count": true}` and a line is generated +- **THEN** the line SHALL have the form `3 x1 x2 x3` where each xi is in [1, 100] + +#### Scenario: Prefix count with custom separator +- **WHEN** the parameter declares `count: {"min": 2, "max": 2, "separator": ","}` and `prefix_count: true` +- **THEN** the line SHALL have the form `2,x1,x2` + +#### Scenario: Prefix count when zero values are drawn +- **WHEN** `count.min` is 0, the drawn count is 0, and `prefix_count` is `true` +- **THEN** the line SHALL be exactly `0` + +#### Scenario: Zero values without prefix count +- **WHEN** `count.min` is 0, the drawn count is 0, and `prefix_count` is `false` or omitted +- **THEN** the line SHALL be an empty line (existing behavior) + +#### Scenario: Prefix count with count omitted +- **WHEN** a parameter declares `prefix_count: true` and omits `count` entirely +- **THEN** the line SHALL have the form `1 value` because omitting `count` is equivalent to `{"min": 1, "max": 1, "separator": " "}` + +#### Scenario: Combined distinct and prefix count +- **WHEN** a parameter declares both `distinct: true` and `prefix_count: true` on a supported type +- **THEN** the line SHALL satisfy both the distinct requirement and the prefix count format + +##### Example: APCS-style line +- **GIVEN** `{"type": "int", "min": 1, "max": 1000000, "count": {"min": 5, "max": 8}, "distinct": true, "prefix_count": true}` +- **WHEN** one line is generated and 6 values are drawn +- **THEN** the line has the form `6 x1 x2 x3 x4 x5 x6` with all xi pairwise distinct and in range + +### Requirement: Construction-time validation for distinct feasibility +When `distinct` is `true`, the generator SHALL validate at construction time (after parsing, before any sampling) that the domain size is greater than or equal to `count.max`, computing the domain size with overflow-safe arithmetic (saturating or widened). For `int` the domain size is `max - min + 1`; for `enum` it is the number of deduplicated `values`. On failure the generator SHALL return a descriptive error; it SHALL NOT silently produce duplicate values and SHALL NOT loop indefinitely. Basic bounds validation (`min <= max`, `count.min <= count.max`) SHALL remain in effect regardless of `distinct`, and all construction-time validation SHALL be active in release/production builds, not only in debug builds. + +#### Scenario: Domain smaller than requested count +- **WHEN** a parameter is `{"type": "int", "min": 1, "max": 3, "count": {"min": 5, "max": 5}, "distinct": true}` +- **THEN** parsing SHALL fail with a construction-time error describing the insufficient domain + +#### Scenario: Enum domain smaller than requested count +- **WHEN** a parameter is `{"type": "enum", "values": ["a", "b", "a"], "count": {"min": 3, "max": 3}, "distinct": true}` +- **THEN** parsing SHALL fail because the deduplicated domain size 2 is less than `count.max` 3 + +#### Scenario: Overflow-safe domain size computation +- **WHEN** a parameter declares `distinct: true` with `min` and `max` spanning the full 64-bit signed integer range +- **THEN** the domain size computation SHALL NOT overflow and validation SHALL succeed for any `count.max` within limits + +#### Scenario: Validation active in release builds +- **WHEN** the error-path cases in this specification are executed against a release/production build of the crate +- **THEN** each case SHALL fail with the same construction-time error behavior as in debug builds + +### Requirement: Backward compatibility of new fields +Parameter specifications that do not declare `distinct` or `prefix_count` SHALL produce output with semantics identical to the current behavior, and omitting either field SHALL be equivalent to declaring it as `false`. + +#### Scenario: Existing specifications unchanged +- **WHEN** an existing parameter specification without `distinct` or `prefix_count` is parsed and generated +- **THEN** the output semantics SHALL be identical to the behavior before this change + +### Requirement: Unknown parameter fields are rejected +The generator SHALL reject, at construction time, any parameter specification containing a field name it does not recognise — at the parameter level and inside `count` — so that a misspelled opt-in field (such as `distinct` or `prefix_count`) fails loudly instead of silently defaulting to `false` and disabling the guarantee it was meant to enable. This is an intentional tightening recorded under the narrow reading of backward compatibility: specifications conforming to the documented schema are unaffected. + +#### Scenario: Misspelled distinct field +- **WHEN** a parameter declares `"distnct": true` (misspelled) +- **THEN** parsing SHALL fail with an error identifying the unknown field + +#### Scenario: Misspelled field nested in count +- **WHEN** a parameter declares `count` containing `"seperator"` (misspelled) +- **THEN** parsing SHALL fail with an error identifying the unknown field diff --git a/openspec/changes/add-distinct-prefix-count/tasks.md b/openspec/changes/add-distinct-prefix-count/tasks.md new file mode 100644 index 0000000..d24a54d --- /dev/null +++ b/openspec/changes/add-distinct-prefix-count/tasks.md @@ -0,0 +1,34 @@ +## 1. Schema 與建構期驗證(parser) + +- [x] 1.1 依 design「欄位命名採 distinct 與 prefix_count」決策,在 crates/random-input-generator/src/parser.rs 的 ParamSpec 全部變體(Int、五種字串型別、Enum、Faker)新增 `distinct: bool` 與 `prefix_count: bool` 頂層欄位(serde default `false`)。行為契約:未宣告欄位的既有 params JSON 解析結果與現行完全一致(Backward compatibility of new fields)。驗證:新增 parser 單元測試——省略欄位時兩欄位為 `false`、宣告 `true`/`false` 時正確反序列化;既有 parser/rng 測試在僅補上新欄位預設值(`distinct: false, prefix_count: false`)的機械性修改後全數通過、斷言邏輯不變(`cargo test -p random-input-generator`)。 +- [x] 1.2 依 design「enum 支援 distinct 而字串型別與 faker 建構期報錯」決策,在 parse_params 實作 distinct 建構期驗證(Construction-time validation for distinct feasibility):(a) 字串型別或 faker 宣告 `distinct: true` 即回傳含 param 名稱的描述性 Err;(b) int 以飽和/寬型別運算計算值域大小 `max − min + 1`,enum 以去重後 values 個數為值域大小,驗證值域大小 ≥ count.max,不足即 Err;(c) 全 i64 範圍(i64::MIN..=i64::MAX)不溢位。既有基本合法性檢查(min ≤ max、count.min ≤ count.max)不論 distinct 皆維持生效。驗證:parser 單元測試覆蓋 M13(值域不足)、M16/M17(不支援型別,含併用 prefix_count)、M21(enum 去重後不足)、全範圍不溢位案例。 + +## 2. 生成邏輯(rng) + +- [x] 2.1 依 design「不放回抽樣採門檻式混合策略」,在 crates/random-input-generator/src/rng.rs 實作 distinct 抽樣(Distinct values within a line):值域大小 ≤ 4 × count.max 走展開 + partial Fisher–Yates 取前 n;否則 rejection sampling + HashSet。int 與去重後 enum 共用同一策略。行為契約:同行值兩兩相異、個數 ∈ [count.min, count.max]、值在值域內、輸出順序非固定排序;緊繃情境(值域大小 = count.max)輸出為值域的隨機排列(M7–M9)。驗證:rng 單元測試以固定 seed 覆蓋一般/緊繃/巨大值域三情境。 +- [x] 2.2 在 rng.rs 實作 prefix_count 輸出(Prefix count line format):行 = [n, v1..vn] 以 count.separator join;適用所有型別含 faker;n=0 時輸出恰為 `0`(M10)、未開 prefix_count 時 n=0 輸出空行(M11);count 省略時輸出 `1值`(M12);與 distinct 併用時兩者同時滿足(M6)。驗證:rng 單元測試覆蓋 M2–M6、M10–M12,含非空格 separator。 +- [x] 2.3 依 design「CI 增加 release 組態測試並移除既有 debug_assert」決策的 rng 部分,移除 rng.rs 中 generate_one 與 random_len 的兩處 `debug_assert!`,改為依賴 parse 層保證的註解說明(不引入 panic 路徑)。行為契約:release 與 debug 組態行為一致,無僅 debug 生效的驗證。驗證:`grep -c "debug_assert" crates/random-input-generator/src/rng.rs` 為 0,且 `cargo test -p random-input-generator` 全綠。 + +## 3. Conformance fixtures(JSON 資料檔 + harness) + +- [x] 3.1 依 design「conformance fixtures 為 JSON 資料檔加 Rust harness」決策,建立 crates/random-input-generator/tests/fixtures/ 目錄與 crates/random-input-generator/tests/conformance.rs harness。fixture JSON 形狀:`{ "params": {...}, "expect": {...} }`,宣告式期望類型至少涵蓋:distinct 驗證、prefix_count 格式(正規表示式或 token 檢查)、值域檢查、建構期錯誤、排列驗證、向後相容輸出語意。行為契約:harness 逐檔載入 fixtures、每檔生成多行並逐一驗證期望,任一 fixture 失敗即測試失敗並輸出 fixture 檔名。驗證:`cargo test -p random-input-generator --test conformance` 執行且列出載入的 fixture 數。 +- [x] 3.2 撰寫追溯矩陣 M1–M21 對應的 fixture JSON 檔(AC-C1 行為組:M1 distinct 基本、M2–M4 三種代表型別的 prefix_count 格式、M5 非空格 separator、M6 併用、M7–M12 邊界、M13–M17 錯誤路徑;AC-C3 enum 組:M18–M21)。行為契約:每列矩陣情境至少一個 fixture,錯誤路徑 fixtures 期望建構期 Err。驗證:conformance harness 全綠,fixture 檔名對應矩陣編號可追溯。 +- [x] 3.3 撰寫 M22 向後相容 fixture(Backward compatibility of new fields):取既有型別各一組未宣告新欄位的 params,期望輸出語意與現行一致(行數、token 數、值域、separator)。驗證:conformance harness 全綠。 + +## 4. 品質檢查測試(Part II) + +- [x] 4.1 [P] 依 design「品質檢查採 Q1 Q2 全量與 Q3 降級」,在 crates/random-input-generator/tests/quality.rs 實作 Q1 輸出順序 smoke test:固定 seed,主要參數組 n=10、值域 [1, 10^6] 生成 1000 行,次要參數組 n=20、值域 [1, 100],「整行完全升冪」與「整行完全降冪」各 ≤ 10 行。行為契約:擋系統性排序(Distinct values within a line 的順序條款)。驗證:`cargo test -p random-input-generator --test quality` 全綠。 +- [x] 4.2 [P] 在 quality.rs 實作 Q2 選值均勻性卡方檢定:min=1、max=20、count.min=count.max=10、固定 seed 生成 10^4 行,X² = Σ(O_v−E)²/E,E=5000,df=19,臨界值 43.82,X² < 43.82 通過。驗證:`cargo test -p random-input-generator --test quality` 全綠,seed 與門檻以註解記錄。 +- [x] 4.3 [P] 在 quality.rs 實作 Q3 效能參考測試(release 組態執行):count.max = 10^4、值域 [1, 10^9] 單行 < 100 ms;值域 [1, 10^4] 取滿(排列情境)< 100 ms。debug 組態下以 `#[ignore]` 或組態判斷跳過,避免 debug 慢速誤報。驗證:`cargo test -p random-input-generator --release --test quality` 全綠。 + +## 5. CI 與文件 + +- [x] 5.1 依 design「CI 增加 release 組態測試並移除既有 debug_assert」決策的 CI 部分,在 .github/workflows/ci.yml 的 Rust 測試步驟後新增 `cargo test --release`(working-directory: crates/random-input-generator)。行為契約:錯誤路徑 fixtures 於 release 組態產物上完整執行(Construction-time validation for distinct feasibility 的 release 條款)。驗證:本機 `cargo test --release -p random-input-generator` 全綠,CI workflow 檔案含 release 測試步驟。 +- [x] 5.2 [P] 更新 crates/random-input-generator/README.md:新增 `distinct` 與 `prefix_count` 欄位規格(含 schema 範例、逐型別支援表、n=0 與 count 省略等全部邊界行為、Q3 效能量測環境標明為 native release)。行為契約:文件涵蓋 spec delta 全部 Requirement 的使用者可見行為(AC-C4 release checklist 項)。驗證:人工比對 README 與 spec delta 的 Requirement 清單,逐項有對應段落。 +- [x] 5.3 新增 changeset(minor)描述兩個新欄位,供後續發版使用。行為契約:`.changeset/` 內有一筆 minor bump 條目涵蓋受影響 npm 套件。驗證:`ls .changeset/*.md` 有新檔且內容標明 minor。 + +## 6. Round 1 硬化(audit + adversarial review 產出) + +- [x] 6.1 依 design「未知欄位建構期拒絕」決策,ParamSpec 與 CountSpec 加 `#[serde(deny_unknown_fields)]`(Unknown parameter fields are rejected)。行為契約:頂層與 count 巢狀的拼錯欄位名於建構期報 `unknown field` 錯誤。驗證:parser 三個 typo 單元測試 + fixtures m23a/m23b 於 conformance harness 通過。 +- [x] 6.2 依 design「窄公開 API generate 取代 pub mod」決策,crates/random-input-generator/src/lib.rs 收回 `pub mod`,新增 `pub fn generate(params_json, count, rng) -> Result, String>`(含 `count > 10_000` 上限錯誤),tests/conformance.rs 與 tests/quality.rs 改用此 API;rng.rs 的 positional tuple 改為具名 CommonFields 存取器。行為契約:繞過 parse_params 的建構路徑自 crate 外不可達;count 超限回傳描述性錯誤。驗證:lib.rs 上限測試 2 例 + 全測試套件 debug/release/--all-features 全綠。 +- [x] 6.3 .github/workflows/ci.yml 兩個 Rust 測試步驟加 `--all-features`(faker 路徑納入 CI 編譯與執行);README 補「未知鍵拒絕」與「separator 為作者責任」節。驗證:本機 `cargo test --all-features` 與 `cargo test --release --all-features` 全綠;README 含兩節內容。 From d21ab96e3570fc05fc552cf5f3fa7cb4d84ded30 Mon Sep 17 00:00:00 2001 From: CXPhoenix <0826@fhsh.tp.edu.tw> Date: Sun, 26 Jul 2026 15:40:39 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=90=9B=20fix:=20Round=202=20audit=20?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E2=80=94=E2=80=94CI=20=E8=A3=9C=E5=9B=9E=20d?= =?UTF-8?q?efault=20features=20=E6=B8=AC=E8=A9=A6=E8=88=87=E8=A8=BA?= =?UTF-8?q?=E6=96=B7=E5=BC=B7=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📋 變更細節分析 - `.github/workflows/ci.yml` 補回 `cargo test`(default features)步驟,形成 default / `--all-features` / `--release --all-features` 三步:Round 1 以 `--all-features` 取代 bare test 造成出貨組態(wasm-pack 以 faker off 建置)在 CI 零覆蓋,`cfg(not(feature = "faker"))` 的拒絕測試也不再執行 - `lib.rs` 的 `MAX_TESTCASES` doc comment 改為誠實敘明僅上界輸入筆數,不再宣稱防住 unbounded allocation(aggregate byte budget 依審查裁決進 backlog,須先補需求 I.5 錯誤列) - `rng.rs` 的 `partial_shuffle_take` 加 release 生效的自我描述 `assert!`(與現行 panic 行為等價,僅將 rand 內部的模糊訊息換成指明違反不變量的訊息) - README separator 責任註記補 enum values 含分隔字元的情形 - design.md 記錄 Round 2 裁決與 backlog(aggregate budget、重複 param key 拒絕、useWasmGenerator discriminated result、WASM 效能量測);tasks.md 補 7.1–7.2 硬化條目 ## 🔧 技術影響 - 無行為變更:合法輸入輸出位元級不變,僅 CI 覆蓋率、診斷訊息與文件正確性 - Round 2 審查確認:rejection 迴圈上限、separator 建構期拒絕、distinct 改名等提案為 Round 1 已否決案重提,維持不採納;useWasmGenerator 吞錯經查證 UI 實有錯誤訊息(audit 高估),列 JS 端後續獨立 change --- .github/workflows/ci.yml | 8 +++++++- crates/random-input-generator/README.md | 7 ++++--- crates/random-input-generator/src/lib.rs | 9 ++++++--- crates/random-input-generator/src/rng.rs | 8 ++++++++ .../changes/add-distinct-prefix-count/design.md | 13 +++++++++++++ openspec/changes/add-distinct-prefix-count/tasks.md | 5 +++++ 6 files changed, 43 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8732bf8..0bec338 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,9 +51,15 @@ jobs: - name: Build generator WASM run: pnpm build:wasm + # Default features first: this is the exact configuration wasm-pack + # ships (faker off), incl. the cfg(not(feature = "faker")) rejection test. + - name: Test Rust crate + run: cargo test + working-directory: crates/random-input-generator + # --all-features: the feature-gated faker paths (incl. its distinct # rejection) are otherwise never compiled or run in CI. - - name: Test Rust crate + - name: Test Rust crate (all features) run: cargo test --all-features working-directory: crates/random-input-generator diff --git a/crates/random-input-generator/README.md b/crates/random-input-generator/README.md index b2d5598..8612c93 100644 --- a/crates/random-input-generator/README.md +++ b/crates/random-input-generator/README.md @@ -35,9 +35,10 @@ guarantee it was meant to enable. **Separators are the author's responsibility.** `count.separator` is joined verbatim and not validated: an empty string, a digit, a newline, or a character -that can occur inside the values themselves (e.g. `,` with `printable_ascii`) -produces output that may be ambiguous to re-split or span multiple lines. -Choose a separator that cannot collide with your value alphabet. +that can occur inside the values themselves (e.g. `,` with `printable_ascii`, +or an `enum` whose `values` contain the separator) produces output that may be +ambiguous to re-split or span multiple lines. Choose a separator that cannot +collide with your value alphabet. ### Types diff --git a/crates/random-input-generator/src/lib.rs b/crates/random-input-generator/src/lib.rs index e8fc0b9..085dd1e 100644 --- a/crates/random-input-generator/src/lib.rs +++ b/crates/random-input-generator/src/lib.rs @@ -7,9 +7,12 @@ use rand::rngs::SmallRng; use serde::Serialize; use wasm_bindgen::prelude::*; -/// Upper bound on the number of testcase inputs per call. Keeps a huge `count` -/// from the JS side from multiplying into unbounded allocation (each input can -/// legally reach `MAX_COUNT` values of up to `MAX_LEN` chars). +/// Upper bound on the number of testcase inputs per call. This bounds ONE +/// factor of the allocation product: a legal spec can still ask for up to +/// `MAX_COUNT × MAX_LEN` chars per param per input (~10^9), so total output +/// remains host-bounded — on wasm32 an oversized but legal request traps on +/// linear-memory limits rather than erroring here. An aggregate byte budget +/// is deliberately out of scope until the requirement's error matrix covers it. const MAX_TESTCASES: usize = 10_000; /// Output of `generate_challenge`: a list of random stdin input strings, diff --git a/crates/random-input-generator/src/rng.rs b/crates/random-input-generator/src/rng.rs index 76c65bd..fbd38dc 100644 --- a/crates/random-input-generator/src/rng.rs +++ b/crates/random-input-generator/src/rng.rs @@ -131,6 +131,14 @@ fn generate_distinct(spec: &ParamSpec, n: usize, rng: &mut R) -> Vec(mut pool: Vec, n: usize, rng: &mut R) -> Vec { + // Active in release too (unlike debug_assert!): if the parse-layer + // invariant is ever broken, fail with a self-describing message instead + // of an opaque gen_range panic deep inside rand. + assert!( + n <= pool.len(), + "partial_shuffle_take: n ({n}) must be <= pool.len() ({})", + pool.len() + ); for i in 0..n { let j = rng.gen_range(i..pool.len()); pool.swap(i, j); diff --git a/openspec/changes/add-distinct-prefix-count/design.md b/openspec/changes/add-distinct-prefix-count/design.md index b35cdf1..cd4db0a 100644 --- a/openspec/changes/add-distinct-prefix-count/design.md +++ b/openspec/changes/add-distinct-prefix-count/design.md @@ -72,3 +72,16 @@ Q1(輸出順序 smoke test:固定 seed 1000 行,完全升冪/降冪各 ### 窄公開 API generate 取代 pub mod `parser` / `rng` 模組維持私有,改提供 `generate(params_json, count, rng) -> Result, String>` 作為唯一 native 入口(整合測試與未來 native host 共用),並對 `count` 參數加上限(10^4,與 MAX_COUNT 同階)回傳錯誤。動機:pub mod 使呼叫端可繞過 parse_params 手工建構 spec,實測觸發 4 個 panic 點與無上界配置;窄 API 使 parse → generate 不可分離,rng 模組內的 unreachable 斷言回復為真正不可達。同時把 rng 內部 8 臂 match 的 positional tuple 改為具名 struct 存取器,使 distinct / prefix_count 欄位互換成為編譯錯誤。否決的替代案:恢復 debug_assert!(需求 I.3 點名的反例)、generate_input 回傳 Result(在抽樣邊界重跑驗證製造第二真相來源,與「建構期報錯」的落點矛盾)、separator 建構期拒絕(破 M22/AC-C2 且把 I.8 明文要求不要寫死的「一 param 一行」假設寫進 validator,改為 README 文件註記)。 + +## Round 2 硬化決策(第二輪 audit + 雙鏡頭 adversarial review 後新增) + +### CI 恢復 default features 測試步驟 + +Round 1 把 `cargo test` 改為 `cargo test --all-features` 造成出貨組態(wasm-pack 以 default features 建置,faker off)在 CI 零覆蓋,`cfg(not(feature = "faker"))` 的拒絕測試也不再執行——這是 Round 1 修法自己引入的覆蓋率退化。修正為三步:`cargo test`(default)、`cargo test --all-features`、`cargo test --release --all-features`。其餘 Round 2 findings 裁決:MAX_TESTCASES 註解改為誠實敘明只上界輸入筆數(aggregate byte budget 與 param 數上限須先在需求 I.5 補錯誤列,進 backlog);enum values 含 separator 同樣降為 README 註記(全稱式拒絕會誤傷 count.max=1 的既有合法 params);partial_shuffle_take 加 release 生效的自我描述 `assert!`(與現行 panic 行為等價,僅改善診斷);rejection 迴圈上限+fallback 與 separator 建構期拒絕為 Round 1 已否決案的重新包裝,無新事實不重翻;useWasmGenerator 吞錯經查證 UI 實際有錯誤訊息顯示(audit 高估),且 JS 端明文出界,改列 backlog。 + +**Backlog(不在本 change,供後續提案):** + +- 記憶體 aggregate budget 與 params 鍵數上限:先補需求 I.5 錯誤列與門檻依據(native/wasm32 環境相依)。 +- `useWasmGenerator.generateChallenge` 改 discriminated result 以在 UI 呈現 parser 錯誤細節:JS 套件獨立 change(已發佈 API 的 breaking 變更)。 +- 拒絕重複 param key(serde last-wins 靜默吞行):先補 I.5 錯誤列,實作需自訂 serde visitor。 +- WASM + Node 環境的 Q3 效能量測:回灌時處理(既存 Non-Goal)。 diff --git a/openspec/changes/add-distinct-prefix-count/tasks.md b/openspec/changes/add-distinct-prefix-count/tasks.md index d24a54d..d01d2ca 100644 --- a/openspec/changes/add-distinct-prefix-count/tasks.md +++ b/openspec/changes/add-distinct-prefix-count/tasks.md @@ -32,3 +32,8 @@ - [x] 6.1 依 design「未知欄位建構期拒絕」決策,ParamSpec 與 CountSpec 加 `#[serde(deny_unknown_fields)]`(Unknown parameter fields are rejected)。行為契約:頂層與 count 巢狀的拼錯欄位名於建構期報 `unknown field` 錯誤。驗證:parser 三個 typo 單元測試 + fixtures m23a/m23b 於 conformance harness 通過。 - [x] 6.2 依 design「窄公開 API generate 取代 pub mod」決策,crates/random-input-generator/src/lib.rs 收回 `pub mod`,新增 `pub fn generate(params_json, count, rng) -> Result, String>`(含 `count > 10_000` 上限錯誤),tests/conformance.rs 與 tests/quality.rs 改用此 API;rng.rs 的 positional tuple 改為具名 CommonFields 存取器。行為契約:繞過 parse_params 的建構路徑自 crate 外不可達;count 超限回傳描述性錯誤。驗證:lib.rs 上限測試 2 例 + 全測試套件 debug/release/--all-features 全綠。 - [x] 6.3 .github/workflows/ci.yml 兩個 Rust 測試步驟加 `--all-features`(faker 路徑納入 CI 編譯與執行);README 補「未知鍵拒絕」與「separator 為作者責任」節。驗證:本機 `cargo test --all-features` 與 `cargo test --release --all-features` 全綠;README 含兩節內容。 + +## 7. Round 2 硬化(第二輪 audit + adversarial review 產出) + +- [x] 7.1 依 design「CI 恢復 default features 測試步驟」決策,.github/workflows/ci.yml 補回 `cargo test`(default features)步驟,形成 default / --all-features / --release --all-features 三步。行為契約:出貨組態(faker off)與 `cfg(not(feature = "faker"))` 拒絕測試在 CI 每次執行。驗證:本機三種組態全綠;ci.yml 含三個 Rust 測試步驟。 +- [x] 7.2 lib.rs 的 MAX_TESTCASES doc comment 改為誠實敘明僅上界輸入筆數(不宣稱防住 unbounded allocation);README separator 節補 enum values 情形;rng.rs partial_shuffle_take 加 release 生效的自我描述 assert!。行為契約:合法輸入行為位元級不變,僅診斷與文件正確性改善。驗證:全測試套件三組態全綠。 From 24619bf2e2ddc8e8683c7ce617d01dbf3bbd8691 Mon Sep 17 00:00:00 2001 From: CXPhoenix <0826@fhsh.tp.edu.tw> Date: Mon, 27 Jul 2026 09:48:26 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20perf:=20Round=203=20au?= =?UTF-8?q?dit=20=E4=BF=AE=E6=AD=A3=E2=80=94=E2=80=94=E6=8A=BD=E6=A8=A3?= =?UTF-8?q?=E5=88=86=E6=94=AF=E6=94=B9=E7=94=A8=E5=AF=A6=E9=9A=9B=20n=20?= =?UTF-8?q?=E8=88=87=E5=85=A7=E9=83=A8=E4=B8=8D=E8=AE=8A=E5=BC=8F=E7=B7=A8?= =?UTF-8?q?=E8=AD=AF=E6=9C=9F=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📋 變更細節分析 - `rng.rs` 的 distinct 抽樣分支由「值域 ≤ 4 × count.max」改為「≤ 4 × n(實際抽出個數)」:count 區間寬、實際只抽少量值時不再展開整個值域(如 count 1..10^4、值域 3×10^4 抽 1 值原需展開 3×10^4 筆)。對外契約不變(需求明文演算法不指定),既有固定 seed 測試因參數組皆 count.min = count.max 而位元不變 - `generate_distinct` 的 `_ =>` 萬用臂展開為顯式 variant 列表:新增 ParamSpec 型別時 rng 端成為編譯錯誤而非 release 期 panic(與 CommonFields 同一原則) - `random_len` 與 parser 的 `validate_len` 統一使用 `div_ceil`(消除潛在溢位不對稱) - CI 補第四步 `cargo test --release`(default features = wasm-pack 出貨組態)滿足 AC-C1-5「正式發行組態產物」字面要求;release 步驟註解改掛 AC-C1-5 論據(src 已無 debug_assert,舊論據過時) - `quality.rs` 修正 Q1 secondary 的錯誤路徑註解(實走 rejection 而非展開),並增列第三組 shuffle-path 順序守門(值域 [1,50]、n=20)——需求 Part II 的兩組參數在本實作門檻策略下皆落 rejection 分支,展開洗牌分支原本沒有系統性排序守門 - README 補「`multiple_of` < 1 視同 1」註記;design.md/tasks.md 同步 Round 3 決策與否決紀錄(multiple_of: 0 建構期拒絕依凍結判例否決進 backlog) ## 🔧 技術影響 - 無對外行為變更:輸出契約、公開 API、fixtures 期望全數不動;效能在寬 count 區間情境改善 - 兩處測試/程式註解的數學錯誤修正,避免後續 reviewer 誤判路徑覆蓋 --- .github/workflows/ci.yml | 12 ++++-- crates/random-input-generator/README.md | 3 ++ crates/random-input-generator/src/rng.rs | 41 ++++++++++++++----- .../random-input-generator/tests/quality.rs | 22 +++++++++- .../add-distinct-prefix-count/design.md | 10 ++++- .../add-distinct-prefix-count/tasks.md | 7 +++- 6 files changed, 77 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0bec338..0ea2743 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,10 +63,16 @@ jobs: run: cargo test --all-features working-directory: crates/random-input-generator - # Release-config run is mandatory: it proves construction-time validation - # (error-path fixtures) survives release builds, where debug_assert! is - # stripped. Also the only config where the Q3 performance tests run. + # Release + default features is the exact shipping configuration + # (wasm-pack builds release with faker off). AC-C1-5 requires the + # error-path fixtures to run at least once against a release-config + # build; this also guards against future debug-only assertions and is + # the only config where the Q3 performance tests run. - name: Test Rust crate (release) + run: cargo test --release + working-directory: crates/random-input-generator + + - name: Test Rust crate (release, all features) run: cargo test --release --all-features working-directory: crates/random-input-generator diff --git a/crates/random-input-generator/README.md b/crates/random-input-generator/README.md index 8612c93..94c71ae 100644 --- a/crates/random-input-generator/README.md +++ b/crates/random-input-generator/README.md @@ -40,6 +40,9 @@ or an `enum` whose `values` contain the separator) produces output that may be ambiguous to re-split or span multiple lines. Choose a separator that cannot collide with your value alphabet. +**`multiple_of` below 1 is treated as 1.** A `multiple_of: 0` does not error; +it behaves exactly like the default. + ### Types | `type` | Fields (defaults) | diff --git a/crates/random-input-generator/src/rng.rs b/crates/random-input-generator/src/rng.rs index fbd38dc..77f361e 100644 --- a/crates/random-input-generator/src/rng.rs +++ b/crates/random-input-generator/src/rng.rs @@ -77,9 +77,13 @@ fn common_fields(spec: &ParamSpec) -> CommonFields<'_> { } /// Threshold factor for choosing the distinct-sampling strategy: domains up to -/// `EXPAND_FACTOR × count.max` are materialised and partially shuffled (exact, -/// handles the tight permutation case); larger domains use rejection sampling, -/// where the per-draw collision probability stays below 1/EXPAND_FACTOR. +/// `EXPAND_FACTOR × n` (the actually-drawn count, not `count.max`) are +/// materialised and partially shuffled (exact, handles the tight permutation +/// case); larger domains use rejection sampling, where the per-draw collision +/// probability stays below 1/EXPAND_FACTOR. Comparing against `n` instead of +/// `count.max` avoids materialising a pool sized for the worst-case count when +/// the actual draw is much smaller (e.g. `count: {min: 1, max: 10000}` drawing +/// `n = 1`). const EXPAND_FACTOR: i128 = 4; /// Sample `n` pairwise-distinct values for a distinct-enabled spec. @@ -87,9 +91,9 @@ const EXPAND_FACTOR: i128 = 4; /// on every other type at construction time. fn generate_distinct(spec: &ParamSpec, n: usize, rng: &mut R) -> Vec { match spec { - ParamSpec::Int { min, max, count, .. } => { + ParamSpec::Int { min, max, .. } => { let domain_size = (*max as i128) - (*min as i128) + 1; - if domain_size <= EXPAND_FACTOR * count.max as i128 { + if domain_size <= EXPAND_FACTOR * n as i128 { // Small domain: materialise and partially shuffle. Covers the // tight case (domain == count.max → random permutation). let pool: Vec = (*min..=*max).collect(); @@ -100,7 +104,7 @@ fn generate_distinct(spec: &ParamSpec, n: usize, rng: &mut R) -> Vec= count.max >= n, so this terminates; with - // domain > EXPAND_FACTOR × count.max the expected number of + // domain > EXPAND_FACTOR × n the expected number of // retries per draw is below 1/(EXPAND_FACTOR - 1). let mut seen = HashSet::with_capacity(n); let mut out = Vec::with_capacity(n); @@ -123,7 +127,20 @@ fn generate_distinct(spec: &ParamSpec, n: usize, rng: &mut R) -> Vec unreachable!("distinct on unsupported types is rejected by parse_params"), + // Explicit arms (no wildcard): adding a ParamSpec variant must be a + // compile error here, not a release-time panic — same principle as + // CommonFields replacing the positional tuple. + ParamSpec::AlphaUpper { .. } + | ParamSpec::AlphaLower { .. } + | ParamSpec::AlphaMixed { .. } + | ParamSpec::HexString { .. } + | ParamSpec::PrintableAscii { .. } => { + unreachable!("distinct on string types is rejected by parse_params") + } + #[cfg(feature = "faker")] + ParamSpec::Faker { .. } => { + unreachable!("distinct on faker types is rejected by parse_params") + } } } @@ -151,8 +168,10 @@ fn partial_shuffle_take(mut pool: Vec, n: usize, rng: &mut R) -> V /// If multiple_of is 1 (the default), this is equivalent to gen_range(min..=max). fn random_len(min_len: usize, max_len: usize, multiple_of: usize, rng: &mut R) -> usize { let step = multiple_of.max(1); - // Smallest multiple of `step` that is >= min_len - let lo = (min_len + step - 1) / step; + // Smallest multiple of `step` that is >= min_len (div_ceil mirrors + // parse_params::validate_len — keeps both sides overflow-safe by the + // same construction). + let lo = min_len.div_ceil(step); // Largest multiple of `step` that is <= max_len let hi = max_len / step; // `parse_params::validate_len` guarantees at least one multiple of `step` @@ -599,8 +618,8 @@ mod tests { #[test] fn distinct_expansion_path_boundary() { - // domain size (8) <= 4 × count.max (2) is false → still must be distinct on - // either path; exercises small-domain sampling with n < domain + // domain size (8) <= 4 × n (8) — exact threshold boundary, lands on + // the materialise-and-shuffle path with n < domain let spec = parse_one( r#"{"n": {"type": "int", "min": 1, "max": 8, "count": {"min": 2, "max": 2}, "distinct": true}}"#, ); diff --git a/crates/random-input-generator/tests/quality.rs b/crates/random-input-generator/tests/quality.rs index 5519a7f..a806005 100644 --- a/crates/random-input-generator/tests/quality.rs +++ b/crates/random-input-generator/tests/quality.rs @@ -47,7 +47,8 @@ fn q1_output_order_not_systematically_sorted_primary() { } /// Q1 (secondary): n = 20, domain [1, 100] — guards against "only unsorted for -/// certain parameters" (this hits the materialise-and-shuffle path). +/// certain parameters". NOTE: 100 > 4 × 20, so this parameter set (given by +/// requirement Part II) also lands on the rejection-sampling path. #[test] fn q1_output_order_not_systematically_sorted_secondary() { let mut rng = SmallRng::seed_from_u64(QUALITY_SEED + 1); @@ -61,6 +62,25 @@ fn q1_output_order_not_systematically_sorted_secondary() { assert!(desc <= 10, "systematic descending order: {desc}/1000 lines fully descending"); } +/// Q1 (tertiary, implementation-local addition): n = 20, domain [1, 50] — +/// 50 <= 4 × 20, so this lands on the materialise-and-shuffle path. Added +/// because both Part II parameter sets fall on the rejection path under this +/// implementation's threshold strategy, leaving the shuffle branch without a +/// systematic-sorting guard (e.g. a truncate-without-shuffle bug would pass +/// Q2 uniformity). Same thresholds; seed self-held as per Part II. +#[test] +fn q1_output_order_not_systematically_sorted_shuffle_path() { + let mut rng = SmallRng::seed_from_u64(QUALITY_SEED + 4); + let lines = lines_for( + r#"{"n": {"type": "int", "min": 1, "max": 50, "count": {"min": 20, "max": 20}, "distinct": true}}"#, + 1000, + &mut rng, + ); + let (asc, desc) = fully_sorted_counts(&lines); + assert!(asc <= 10, "systematic ascending order: {asc}/1000 lines fully ascending"); + assert!(desc <= 10, "systematic descending order: {desc}/1000 lines fully descending"); +} + /// Q2: value-selection uniformity. min=1, max=20, count 10, fixed seed, 10^4 /// lines. Chi-squared over the 20 per-value totals: E = 5000, df = 19, /// alpha = 0.001 → critical value 43.82. Without-replacement sampling makes the diff --git a/openspec/changes/add-distinct-prefix-count/design.md b/openspec/changes/add-distinct-prefix-count/design.md index cd4db0a..06cf27d 100644 --- a/openspec/changes/add-distinct-prefix-count/design.md +++ b/openspec/changes/add-distinct-prefix-count/design.md @@ -30,8 +30,8 @@ ### 不放回抽樣採門檻式混合策略 -- 值域大小 ≤ 4 × count.max:將值域展開為陣列,partial Fisher–Yates 洗牌取前 n 個。最多展開 4 × 10^4 個元素(MAX_COUNT = 10^4),緊繃情境(值域大小 = count.max,排列)一次到位。 -- 值域大小 > 4 × count.max:rejection sampling + HashSet 去重,單次碰撞機率 < 1/4,期望重抽次數有上界,巨大值域(如 [1, 10^9] 取 10^4 個)不需展開。 +- 值域大小 ≤ 4 × n(實際抽出個數;Round 3 由 count.max 修正為 n,避免 count 區間很寬、實際只抽少量值時展開整個值域):將值域展開為陣列,partial Fisher–Yates 洗牌取前 n 個。最多展開 4 × 10^4 個元素(MAX_COUNT = 10^4),緊繃情境(值域大小 = n,排列)一次到位。 +- 值域大小 > 4 × n:rejection sampling + HashSet 去重,單次碰撞機率 < 1/4,期望重抽次數有上界,巨大值域(如 [1, 10^9] 取 10^4 個)不需展開。 - 兩路徑輸出順序天然隨機,滿足「不得固定排序」規範,無需額外洗牌。門檻常數實作時可微調,但兩種極端行為不變。 - 值域大小一律以飽和運算(`i64::saturating_sub` 後轉 `u64`/`u128`)計算,避免 `max − min + 1` 溢位。 @@ -85,3 +85,9 @@ Round 1 把 `cargo test` 改為 `cargo test --all-features` 造成出貨組態 - `useWasmGenerator.generateChallenge` 改 discriminated result 以在 UI 呈現 parser 錯誤細節:JS 套件獨立 change(已發佈 API 的 breaking 變更)。 - 拒絕重複 param key(serde last-wins 靜默吞行):先補 I.5 錯誤列,實作需自訂 serde visitor。 - WASM + Node 環境的 Q3 效能量測:回灌時處理(既存 Non-Goal)。 + +## Round 3 硬化決策(第三輪 audit + 雙鏡頭 adversarial review 後新增) + +### 抽樣分支改用實際抽出個數與內部不變式編譯期化 + +抽樣策略分支由「值域大小 ≤ 4 × count.max」修正為「≤ 4 × n(實際抽出個數)」:count 區間寬、實際抽出少量值時不再展開整個值域(如 count 1..10^4、值域 3×10^4 抽 1 個值原需展開 3×10^4 筆)。正確性不變(建構期保證值域 ≥ count.max ≥ n;rejection 碰撞率仍 < 1/4 且配置量收緊);需求 I.3 明文演算法不指定,屬實作自由。既有品質測試參數組皆 count.min = count.max,固定 seed 串流位元不變,fixtures 期望為性質式不受影響。同輪一併:generate_distinct 的萬用臂展開為顯式 variant 列表(新增型別成為編譯錯誤而非 release panic,與 CommonFields 同一原則);random_len 與 validate_len 統一 div_ceil;CI 補第四步 `cargo test --release`(default features 即出貨組態,滿足 AC-C1-5 字面要求,release 步驟註解改掛 AC-C1-5 論據);quality.rs 修正 Q1 secondary 的錯誤路徑註解並增列第三組(值域 [1,50]、n=20,覆蓋展開洗牌分支的系統性排序守門——Part II 給定的兩組參數在本實作門檻策略下皆落 rejection 分支);README 補 multiple_of < 1 視同 1 註記。否決:建構期拒絕 multiple_of: 0(既有合法欄位值、I.5 無對應列,破 M22——與 separator 案同級處置降為文件),進 backlog「I.5 補既有數值欄位值域錯誤列」。 diff --git a/openspec/changes/add-distinct-prefix-count/tasks.md b/openspec/changes/add-distinct-prefix-count/tasks.md index d01d2ca..205307f 100644 --- a/openspec/changes/add-distinct-prefix-count/tasks.md +++ b/openspec/changes/add-distinct-prefix-count/tasks.md @@ -5,7 +5,7 @@ ## 2. 生成邏輯(rng) -- [x] 2.1 依 design「不放回抽樣採門檻式混合策略」,在 crates/random-input-generator/src/rng.rs 實作 distinct 抽樣(Distinct values within a line):值域大小 ≤ 4 × count.max 走展開 + partial Fisher–Yates 取前 n;否則 rejection sampling + HashSet。int 與去重後 enum 共用同一策略。行為契約:同行值兩兩相異、個數 ∈ [count.min, count.max]、值在值域內、輸出順序非固定排序;緊繃情境(值域大小 = count.max)輸出為值域的隨機排列(M7–M9)。驗證:rng 單元測試以固定 seed 覆蓋一般/緊繃/巨大值域三情境。 +- [x] 2.1 依 design「不放回抽樣採門檻式混合策略」,在 crates/random-input-generator/src/rng.rs 實作 distinct 抽樣(Distinct values within a line):值域大小 ≤ 4 × n(實際抽出個數,Round 3 修正)走展開 + partial Fisher–Yates 取前 n;否則 rejection sampling + HashSet。int 與去重後 enum 共用同一策略。行為契約:同行值兩兩相異、個數 ∈ [count.min, count.max]、值在值域內、輸出順序非固定排序;緊繃情境(值域大小 = count.max)輸出為值域的隨機排列(M7–M9)。驗證:rng 單元測試以固定 seed 覆蓋一般/緊繃/巨大值域三情境。 - [x] 2.2 在 rng.rs 實作 prefix_count 輸出(Prefix count line format):行 = [n, v1..vn] 以 count.separator join;適用所有型別含 faker;n=0 時輸出恰為 `0`(M10)、未開 prefix_count 時 n=0 輸出空行(M11);count 省略時輸出 `1值`(M12);與 distinct 併用時兩者同時滿足(M6)。驗證:rng 單元測試覆蓋 M2–M6、M10–M12,含非空格 separator。 - [x] 2.3 依 design「CI 增加 release 組態測試並移除既有 debug_assert」決策的 rng 部分,移除 rng.rs 中 generate_one 與 random_len 的兩處 `debug_assert!`,改為依賴 parse 層保證的註解說明(不引入 panic 路徑)。行為契約:release 與 debug 組態行為一致,無僅 debug 生效的驗證。驗證:`grep -c "debug_assert" crates/random-input-generator/src/rng.rs` 為 0,且 `cargo test -p random-input-generator` 全綠。 @@ -37,3 +37,8 @@ - [x] 7.1 依 design「CI 恢復 default features 測試步驟」決策,.github/workflows/ci.yml 補回 `cargo test`(default features)步驟,形成 default / --all-features / --release --all-features 三步。行為契約:出貨組態(faker off)與 `cfg(not(feature = "faker"))` 拒絕測試在 CI 每次執行。驗證:本機三種組態全綠;ci.yml 含三個 Rust 測試步驟。 - [x] 7.2 lib.rs 的 MAX_TESTCASES doc comment 改為誠實敘明僅上界輸入筆數(不宣稱防住 unbounded allocation);README separator 節補 enum values 情形;rng.rs partial_shuffle_take 加 release 生效的自我描述 assert!。行為契約:合法輸入行為位元級不變,僅診斷與文件正確性改善。驗證:全測試套件三組態全綠。 + +## 8. Round 3 硬化(第三輪 audit + adversarial review 產出) + +- [x] 8.1 依 design「抽樣分支改用實際抽出個數與內部不變式編譯期化」決策:rng.rs 分支改用實際 n(含註解同步)、generate_distinct 萬用臂展開為顯式 variant、random_len 統一 div_ceil。行為契約:對外輸出契約不變(演算法不指定範疇),新增 ParamSpec variant 時 rng 端成為編譯錯誤。驗證:四種組態(default / --all-features / --release / --release --all-features)全綠。 +- [x] 8.2 CI 補第四步 `cargo test --release`(default features = 出貨組態,AC-C1-5 字面滿足)並重寫 release 步驟註解;quality.rs 修正 Q1 secondary 路徑註解、增列第三組 q1_output_order_not_systematically_sorted_shuffle_path(值域 [1,50]、n=20);README 補 multiple_of < 1 視同 1。行為契約:出貨組態在 CI 每次以 release 執行錯誤路徑 fixtures;展開洗牌分支有系統性排序守門。驗證:四種組態全綠。 From 0802db4c58209351694e439788664b924b9c35ee Mon Sep 17 00:00:00 2001 From: CXPhoenix <0826@fhsh.tp.edu.tw> Date: Mon, 27 Jul 2026 09:54:18 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=93=9D=20docs:=20=E6=9C=80=E7=B5=82?= =?UTF-8?q?=E6=94=B6=E6=96=82=20audit=20=E5=BE=8C=E7=9A=84=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E8=A3=9C=E6=AD=A3=E2=80=94=E2=80=94changeset=20?= =?UTF-8?q?=E8=A3=9C=E8=A8=98=E8=A1=8C=E7=82=BA=E6=94=B6=E7=B7=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📋 變更細節分析 - `.changeset/distinct-prefix-count.md` 補記「未知欄位名從靜默忽略改為建構期報錯」的行為收緊警示(升級端唯一會讀到的 artifact,原本漏記此 breaking 收緊) - root README 補 `generatorParams` schema 指引連結(JS 使用者原本沒有可達路徑讀到「未知鍵拒絕」說明) - fixtures m14/m15 補 `error_contains` 錨定(原本任何錯誤都能通過,追溯宣稱未被驗證) - design.md 修正兩處與實作不符的過時敘述(門檻 4 × count.max → 4 × n;飽和運算 → i128 寬型別),backlog 補 harness deny_unknown_fields struct 化與 rejection 路徑均勻性測試兩項 - rng.rs 緊繃情境註解由 domain == count.max 改述為 domain == n ## 🔧 技術影響 - 無程式行為變更;四種測試組態全綠 --- .changeset/distinct-prefix-count.md | 2 ++ README.md | 2 +- crates/random-input-generator/src/rng.rs | 2 +- .../tests/fixtures/m14_min_greater_than_max.json | 5 +++-- .../tests/fixtures/m15_count_min_greater_than_max.json | 5 +++-- openspec/changes/add-distinct-prefix-count/design.md | 5 +++-- 6 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.changeset/distinct-prefix-count.md b/.changeset/distinct-prefix-count.md index f2b328c..f535b66 100644 --- a/.changeset/distinct-prefix-count.md +++ b/.changeset/distinct-prefix-count.md @@ -6,3 +6,5 @@ - `distinct: true` — 同一行抽出的值兩兩相異。`int` 與 `enum`(values 先去重)支援;字串型別與 `faker` 宣告即於建構期報錯。值域不足 `count.max` 時建構期報錯;值域恰好等於 `count.max` 時輸出為隨機排列。 - `prefix_count: true` — 行首以 `count.separator` join 語意輸出實際個數 `n`(APCS 慣例的 `n x1 … xn` 格式);適用所有型別;`n = 0` 時該行輸出恰為 `0`。 + +**行為收緊(請注意)**:params 內的**未知欄位名**(含 `count` 巢狀內)從「靜默忽略」改為**建構期報錯**——拼錯的欄位(如 `"distnct"`、`"prefix-count"`)過去會被無聲丟棄並停用其保證,現在會使 `generate_challenge` 回傳錯誤。若你的 params 夾帶額外鍵(如註解用途的 `"description"`),升級後需移除。完整 schema 見 `crates/random-input-generator/README.md`。 diff --git a/README.md b/README.md index 62d69ce..bb7d459 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Everything runs in the browser — **no server, and no COOP/COEP / cross-origin | [`@cxphoenix/vp-wasm-coding`](packages/vitepress-code-runner) | VitePress layer: the SSR-safe `CodeRunner` component, composables, a configurable asset base, a Vite asset plugin, and a pluggable editor (default CodeMirror). | | [`@cxphoenix/vp-wasm-coding-core`](packages/code-runner-core) | Framework-agnostic engine (pure TypeScript, no Vue): the Pyodide module Worker and the executor/runner controllers. Use it directly outside VitePress. | -The generator WASM ships **inside** `@cxphoenix/vp-wasm-coding` — a single install enables `generate_challenge` with zero extra configuration. +The generator WASM ships **inside** `@cxphoenix/vp-wasm-coding` — a single install enables `generate_challenge` with zero extra configuration. The full `generatorParams` schema (types, `count`, `distinct`, `prefix_count`, limits — note that unknown keys are rejected) is documented in [`crates/random-input-generator/README.md`](crates/random-input-generator/README.md). ## Install diff --git a/crates/random-input-generator/src/rng.rs b/crates/random-input-generator/src/rng.rs index 77f361e..a54be6b 100644 --- a/crates/random-input-generator/src/rng.rs +++ b/crates/random-input-generator/src/rng.rs @@ -95,7 +95,7 @@ fn generate_distinct(spec: &ParamSpec, n: usize, rng: &mut R) -> Vec = (*min..=*max).collect(); partial_shuffle_take(pool, n, rng) .into_iter() diff --git a/crates/random-input-generator/tests/fixtures/m14_min_greater_than_max.json b/crates/random-input-generator/tests/fixtures/m14_min_greater_than_max.json index ada6d1c..562a6a3 100644 --- a/crates/random-input-generator/tests/fixtures/m14_min_greater_than_max.json +++ b/crates/random-input-generator/tests/fixtures/m14_min_greater_than_max.json @@ -1,5 +1,5 @@ { - "description": "M14: min > max — construction-time error (release config too)", + "description": "M14: min > max \u2014 construction-time error (release config too)", "params": { "n": { "type": "int", @@ -8,6 +8,7 @@ } }, "expect": { - "error": true + "error": true, + "error_contains": "must be <= max" } } diff --git a/crates/random-input-generator/tests/fixtures/m15_count_min_greater_than_max.json b/crates/random-input-generator/tests/fixtures/m15_count_min_greater_than_max.json index ad9e86d..c8e2830 100644 --- a/crates/random-input-generator/tests/fixtures/m15_count_min_greater_than_max.json +++ b/crates/random-input-generator/tests/fixtures/m15_count_min_greater_than_max.json @@ -1,5 +1,5 @@ { - "description": "M15: count.min > count.max — construction-time error (release config too)", + "description": "M15: count.min > count.max \u2014 construction-time error (release config too)", "params": { "n": { "type": "int", @@ -12,6 +12,7 @@ } }, "expect": { - "error": true + "error": true, + "error_contains": "count.min" } } diff --git a/openspec/changes/add-distinct-prefix-count/design.md b/openspec/changes/add-distinct-prefix-count/design.md index 06cf27d..ec6f3e0 100644 --- a/openspec/changes/add-distinct-prefix-count/design.md +++ b/openspec/changes/add-distinct-prefix-count/design.md @@ -33,7 +33,7 @@ - 值域大小 ≤ 4 × n(實際抽出個數;Round 3 由 count.max 修正為 n,避免 count 區間很寬、實際只抽少量值時展開整個值域):將值域展開為陣列,partial Fisher–Yates 洗牌取前 n 個。最多展開 4 × 10^4 個元素(MAX_COUNT = 10^4),緊繃情境(值域大小 = n,排列)一次到位。 - 值域大小 > 4 × n:rejection sampling + HashSet 去重,單次碰撞機率 < 1/4,期望重抽次數有上界,巨大值域(如 [1, 10^9] 取 10^4 個)不需展開。 - 兩路徑輸出順序天然隨機,滿足「不得固定排序」規範,無需額外洗牌。門檻常數實作時可微調,但兩種極端行為不變。 -- 值域大小一律以飽和運算(`i64::saturating_sub` 後轉 `u64`/`u128`)計算,避免 `max − min + 1` 溢位。 +- 值域大小一律以 i128 寬型別運算(`(max as i128) - (min as i128) + 1`)計算,避免 `max − min + 1` 溢位(實作定案:寬型別而非飽和運算)。 ### conformance fixtures 為 JSON 資料檔加 Rust harness @@ -57,7 +57,7 @@ Q1(輸出順序 smoke test:固定 seed 1000 行,完全升冪/降冪各 ## Risks / Trade-offs -- [rejection sampling 在門檻邊界附近效能抖動] → 門檻 4 × count.max 保證碰撞機率 < 1/4,期望重試次數 < 4/3 倍;Q3 效能測試守住 100 ms 上限。 +- [rejection sampling 在門檻邊界附近效能抖動] → 門檻 4 × n(Round 3 後)保證碰撞機率 < 1/4,期望重試次數 < 4/3 倍;Q3 效能測試守住 100 ms 上限。 - [`i64` 全值域(如 [i64::MIN, i64::MAX])值域大小超過 u64] → 以 u128 或飽和語意計算值域大小;值域大小只需與 count.max(≤ 10^4)比較,飽和到上限即可判定「足夠大」。 - [固定 seed 統計測試在演算法變更時可能翻紅] → seed 與門檻自持並記錄於測試註解,變更時依 Part II 條款由 review 把關。 - [fixtures 宣告式期望類型設計過窄,未來 fixture 表達不了新行為] → 期望類型以 M1–M22 全矩陣驗證過再定案;新增期望類型屬向後相容擴充。 @@ -85,6 +85,7 @@ Round 1 把 `cargo test` 改為 `cargo test --all-features` 造成出貨組態 - `useWasmGenerator.generateChallenge` 改 discriminated result 以在 UI 呈現 parser 錯誤細節:JS 套件獨立 change(已發佈 API 的 breaking 變更)。 - 拒絕重複 param key(serde last-wins 靜默吞行):先補 I.5 錯誤列,實作需自訂 serde visitor。 - WASM + Node 環境的 Q3 效能量測:回灌時處理(既存 Non-Goal)。 +- conformance harness 的期望鍵改用 deny_unknown_fields struct 反序列化(防 fixture 拼錯鍵靜默通過,鏡射產品端硬化);rejection 路徑的均勻性統計測試(現僅 shuffle 路徑有 Q2)。 ## Round 3 硬化決策(第三輪 audit + 雙鏡頭 adversarial review 後新增) From 160ce4cee0fddad035c34b96423ab5d5d4284a49 Mon Sep 17 00:00:00 2001 From: CXPhoenix <0826@fhsh.tp.edu.tw> Date: Mon, 27 Jul 2026 09:55:04 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=94=A7=20chore:=20archive=20add-disti?= =?UTF-8?q?nct-prefix-count=20change=20=E4=B8=A6=E5=90=8C=E6=AD=A5=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📋 變更細節分析 - change 目錄移入 `openspec/changes/archive/2026-07-27-add-distinct-prefix-count/`(含 proposal / design / spec delta / tasks 與 unarchive snapshot) - delta spec 套用回主規格 `openspec/specs/random-input-generator/spec.md`:新增 5 條 requirements(Distinct values within a line、Prefix count line format、Construction-time validation for distinct feasibility、Backward compatibility of new fields、Unknown parameter fields are rejected),含 @trace 注入 ## 🔧 技術影響 - Spectra workflow 完結:propose → apply → 3 輪 audit/adversarial review → archive;主規格自此為 distinct / prefix_count 行為的單一真相來源 --- .../.openspec.yaml | 2 + .../design.md | 0 .../proposal.md | 0 .../specs/random-input-generator/spec.md | 0 .../tasks.md | 0 openspec/specs/random-input-generator/spec.md | 177 ++++++++++++++++++ 6 files changed, 179 insertions(+) rename openspec/changes/{add-distinct-prefix-count => archive/2026-07-27-add-distinct-prefix-count}/.openspec.yaml (60%) rename openspec/changes/{add-distinct-prefix-count => archive/2026-07-27-add-distinct-prefix-count}/design.md (100%) rename openspec/changes/{add-distinct-prefix-count => archive/2026-07-27-add-distinct-prefix-count}/proposal.md (100%) rename openspec/changes/{add-distinct-prefix-count => archive/2026-07-27-add-distinct-prefix-count}/specs/random-input-generator/spec.md (100%) rename openspec/changes/{add-distinct-prefix-count => archive/2026-07-27-add-distinct-prefix-count}/tasks.md (100%) diff --git a/openspec/changes/add-distinct-prefix-count/.openspec.yaml b/openspec/changes/archive/2026-07-27-add-distinct-prefix-count/.openspec.yaml similarity index 60% rename from openspec/changes/add-distinct-prefix-count/.openspec.yaml rename to openspec/changes/archive/2026-07-27-add-distinct-prefix-count/.openspec.yaml index 924f273..8ca3afa 100644 --- a/openspec/changes/add-distinct-prefix-count/.openspec.yaml +++ b/openspec/changes/archive/2026-07-27-add-distinct-prefix-count/.openspec.yaml @@ -2,3 +2,5 @@ schema: spec-driven created: 2026-07-26 created_by: CXPhoenix <0826@fhsh.tp.edu.tw> created_with: claude +archived_by: CXPhoenix <0826@fhsh.tp.edu.tw> +archived_at: 2026-07-27 diff --git a/openspec/changes/add-distinct-prefix-count/design.md b/openspec/changes/archive/2026-07-27-add-distinct-prefix-count/design.md similarity index 100% rename from openspec/changes/add-distinct-prefix-count/design.md rename to openspec/changes/archive/2026-07-27-add-distinct-prefix-count/design.md diff --git a/openspec/changes/add-distinct-prefix-count/proposal.md b/openspec/changes/archive/2026-07-27-add-distinct-prefix-count/proposal.md similarity index 100% rename from openspec/changes/add-distinct-prefix-count/proposal.md rename to openspec/changes/archive/2026-07-27-add-distinct-prefix-count/proposal.md diff --git a/openspec/changes/add-distinct-prefix-count/specs/random-input-generator/spec.md b/openspec/changes/archive/2026-07-27-add-distinct-prefix-count/specs/random-input-generator/spec.md similarity index 100% rename from openspec/changes/add-distinct-prefix-count/specs/random-input-generator/spec.md rename to openspec/changes/archive/2026-07-27-add-distinct-prefix-count/specs/random-input-generator/spec.md diff --git a/openspec/changes/add-distinct-prefix-count/tasks.md b/openspec/changes/archive/2026-07-27-add-distinct-prefix-count/tasks.md similarity index 100% rename from openspec/changes/add-distinct-prefix-count/tasks.md rename to openspec/changes/archive/2026-07-27-add-distinct-prefix-count/tasks.md diff --git a/openspec/specs/random-input-generator/spec.md b/openspec/specs/random-input-generator/spec.md index 1552b63..9d16ad1 100644 --- a/openspec/specs/random-input-generator/spec.md +++ b/openspec/specs/random-input-generator/spec.md @@ -214,4 +214,181 @@ tests: - packages/vitepress-code-runner/src/composables/useWasmGenerator.spec.ts - packages/code-runner-core/src/__tests__/pyodide-worker-generate.spec.ts - packages/vitepress-code-runner/src/components/CodeRunner.spec.ts +--> + +--- +### Requirement: Distinct values within a line +The generator SHALL accept an optional boolean field `distinct` at the top level of a parameter specification (sibling of `type` and `count`), defaulting to `false`. When `distinct` is `true`, all values generated for that parameter within a single line (one `count` batch) SHALL be pairwise distinct. Distinctness across different parameters is NOT guaranteed. Support is per-type: `int` and `enum` SHALL be supported; string types (`alpha_upper`, `alpha_lower`, `alpha_mixed`, `hex_string`, `printable_ascii`) and `faker` SHALL be rejected with a construction-time error when `distinct: true` is declared. For `enum`, the value domain SHALL be the deduplicated `values` list. The output order of the distinct values SHALL NOT be a fixed sorted order imposed by the implementation. + +#### Scenario: Distinct integers within one line +- **WHEN** a parameter is `{"type": "int", "min": 1, "max": 1000000, "count": {"min": 5, "max": 20}, "distinct": true}` and a line is generated +- **THEN** the line SHALL contain between 5 and 20 values, each in [1, 1000000], all pairwise distinct + +#### Scenario: Tight domain produces a permutation +- **WHEN** a parameter is `{"type": "int", "min": 1, "max": 5, "count": {"min": 5, "max": 5}, "distinct": true}` and a line is generated +- **THEN** the line SHALL be a random permutation of the domain {1, 2, 3, 4, 5} + +##### Example: permutation of a tight domain +- **GIVEN** `{"type": "int", "min": 1, "max": 5, "count": {"min": 5, "max": 5}, "distinct": true}` +- **WHEN** one line is generated +- **THEN** the line contains exactly the values 1, 2, 3, 4, 5 in an order chosen at random (e.g. `3 1 5 2 4`) + +#### Scenario: Distinct enum values +- **WHEN** a parameter is `{"type": "enum", "values": ["red", "green", "blue", "red"], "count": {"min": 3, "max": 3}, "distinct": true}` and a line is generated +- **THEN** the line SHALL be a random permutation of the deduplicated values {red, green, blue} + +#### Scenario: Distinct declared on an unsupported type +- **WHEN** a parameter of a string type or `faker` type declares `distinct: true` +- **THEN** parsing SHALL fail with a construction-time error naming the parameter; the generator SHALL NOT silently ignore the field + + + + +--- +### Requirement: Prefix count line format +The generator SHALL accept an optional boolean field `prefix_count` at the top level of a parameter specification, defaulting to `false`. When `prefix_count` is `true`, the line SHALL be the token sequence consisting of the actual generated count `n` followed by the `n` generated values, joined by `count.separator` (join semantics: the separator appears only between tokens). `n` SHALL be the actual number of values drawn from [count.min, count.max], not `count.max`. `prefix_count` SHALL apply to all parameter types. When `n = 0`, the line SHALL be exactly `0` with no trailing separator and no values. `prefix_count` SHALL NOT relax the per-type support rules of `distinct`. + +#### Scenario: Prefix count with default separator +- **WHEN** a parameter is `{"type": "int", "min": 1, "max": 100, "count": {"min": 3, "max": 3}, "prefix_count": true}` and a line is generated +- **THEN** the line SHALL have the form `3 x1 x2 x3` where each xi is in [1, 100] + +#### Scenario: Prefix count with custom separator +- **WHEN** the parameter declares `count: {"min": 2, "max": 2, "separator": ","}` and `prefix_count: true` +- **THEN** the line SHALL have the form `2,x1,x2` + +#### Scenario: Prefix count when zero values are drawn +- **WHEN** `count.min` is 0, the drawn count is 0, and `prefix_count` is `true` +- **THEN** the line SHALL be exactly `0` + +#### Scenario: Zero values without prefix count +- **WHEN** `count.min` is 0, the drawn count is 0, and `prefix_count` is `false` or omitted +- **THEN** the line SHALL be an empty line (existing behavior) + +#### Scenario: Prefix count with count omitted +- **WHEN** a parameter declares `prefix_count: true` and omits `count` entirely +- **THEN** the line SHALL have the form `1 value` because omitting `count` is equivalent to `{"min": 1, "max": 1, "separator": " "}` + +#### Scenario: Combined distinct and prefix count +- **WHEN** a parameter declares both `distinct: true` and `prefix_count: true` on a supported type +- **THEN** the line SHALL satisfy both the distinct requirement and the prefix count format + +##### Example: APCS-style line +- **GIVEN** `{"type": "int", "min": 1, "max": 1000000, "count": {"min": 5, "max": 8}, "distinct": true, "prefix_count": true}` +- **WHEN** one line is generated and 6 values are drawn +- **THEN** the line has the form `6 x1 x2 x3 x4 x5 x6` with all xi pairwise distinct and in range + + + + +--- +### Requirement: Construction-time validation for distinct feasibility +When `distinct` is `true`, the generator SHALL validate at construction time (after parsing, before any sampling) that the domain size is greater than or equal to `count.max`, computing the domain size with overflow-safe arithmetic (saturating or widened). For `int` the domain size is `max - min + 1`; for `enum` it is the number of deduplicated `values`. On failure the generator SHALL return a descriptive error; it SHALL NOT silently produce duplicate values and SHALL NOT loop indefinitely. Basic bounds validation (`min <= max`, `count.min <= count.max`) SHALL remain in effect regardless of `distinct`, and all construction-time validation SHALL be active in release/production builds, not only in debug builds. + +#### Scenario: Domain smaller than requested count +- **WHEN** a parameter is `{"type": "int", "min": 1, "max": 3, "count": {"min": 5, "max": 5}, "distinct": true}` +- **THEN** parsing SHALL fail with a construction-time error describing the insufficient domain + +#### Scenario: Enum domain smaller than requested count +- **WHEN** a parameter is `{"type": "enum", "values": ["a", "b", "a"], "count": {"min": 3, "max": 3}, "distinct": true}` +- **THEN** parsing SHALL fail because the deduplicated domain size 2 is less than `count.max` 3 + +#### Scenario: Overflow-safe domain size computation +- **WHEN** a parameter declares `distinct: true` with `min` and `max` spanning the full 64-bit signed integer range +- **THEN** the domain size computation SHALL NOT overflow and validation SHALL succeed for any `count.max` within limits + +#### Scenario: Validation active in release builds +- **WHEN** the error-path cases in this specification are executed against a release/production build of the crate +- **THEN** each case SHALL fail with the same construction-time error behavior as in debug builds + + + + +--- +### Requirement: Backward compatibility of new fields +Parameter specifications that do not declare `distinct` or `prefix_count` SHALL produce output with semantics identical to the current behavior, and omitting either field SHALL be equivalent to declaring it as `false`. + +#### Scenario: Existing specifications unchanged +- **WHEN** an existing parameter specification without `distinct` or `prefix_count` is parsed and generated +- **THEN** the output semantics SHALL be identical to the behavior before this change + + + + +--- +### Requirement: Unknown parameter fields are rejected +The generator SHALL reject, at construction time, any parameter specification containing a field name it does not recognise — at the parameter level and inside `count` — so that a misspelled opt-in field (such as `distinct` or `prefix_count`) fails loudly instead of silently defaulting to `false` and disabling the guarantee it was meant to enable. This is an intentional tightening recorded under the narrow reading of backward compatibility: specifications conforming to the documented schema are unaffected. + +#### Scenario: Misspelled distinct field +- **WHEN** a parameter declares `"distnct": true` (misspelled) +- **THEN** parsing SHALL fail with an error identifying the unknown field + +#### Scenario: Misspelled field nested in count +- **WHEN** a parameter declares `count` containing `"seperator"` (misspelled) +- **THEN** parsing SHALL fail with an error identifying the unknown field + + \ No newline at end of file