Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/distinct-prefix-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@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`。

**行為收緊(請注意)**:params 內的**未知欄位名**(含 `count` 巢狀內)從「靜默忽略」改為**建構期報錯**——拼錯的欄位(如 `"distnct"`、`"prefix-count"`)過去會被無聲丟棄並停用其保證,現在會使 `generate_challenge` 回傳錯誤。若你的 params 夾帶額外鍵(如註解用途的 `"description"`),升級後需移除。完整 schema 見 `crates/random-input-generator/README.md`。
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,31 @@ 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 (all features)
run: cargo test --all-features
working-directory: crates/random-input-generator

# 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

- name: Build packages
run: pnpm -r build

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
131 changes: 131 additions & 0 deletions crates/random-input-generator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# 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`,
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) |
| ------ | ----------------- |
| `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 `1<sep>value`.
- 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).
61 changes: 49 additions & 12 deletions crates/random-input-generator/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
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. 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,
/// one per testcase. The frontend feeds each to the Python generator to
/// produce the corresponding expected output.
Expand All @@ -14,6 +23,25 @@ struct GeneratedInputs {
inputs: Vec<String>,
}

/// 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<Vec<String>, 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(&params, rng)).collect())
}

/// Generate random input strings from a JSON params specification.
///
/// # Arguments
Expand All @@ -25,11 +53,8 @@ struct GeneratedInputs {
/// `{ inputs: [string, ...] }` — one input string per testcase.
#[wasm_bindgen]
pub fn generate_challenge(params_json: &str, count: usize) -> Result<JsValue, JsError> {
let params = parser::parse_params(params_json).map_err(|e| JsError::new(&e))?;
let mut rng = SmallRng::from_entropy();
let inputs: Vec<String> = (0..count)
.map(|_| rng::generate_input(&params, &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()))
}
Expand All @@ -39,13 +64,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<String> = (0..5)
.map(|_| rng::generate_input(&params, &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();
Expand All @@ -54,8 +76,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);
}
}
Loading
Loading