diff --git a/Sources/SwiftLM/Server.swift b/Sources/SwiftLM/Server.swift index 52da5c3..a4bb8da 100644 --- a/Sources/SwiftLM/Server.swift +++ b/Sources/SwiftLM/Server.swift @@ -2380,13 +2380,16 @@ func handleChatCompletion( prefillStepSize: config.prefillSize ) generateParams.prefill.progress = forwardPrefillProgress + // ── Seed ── + // The samplers draw from their own RandomState, which only `GenerateParameters.seed` + // seeds; seeding the global generator alone left the request's `seed` without any + // effect (#219). The global generator is still seeded for the paths that call + // MLXRandom directly. + let seed = samplingSeed(chatReq.seed) + generateParams.seed = seed + if let seed { MLXRandom.seed(seed) } let params = generateParams - // ── Seed for deterministic generation ── - if let seed = chatReq.seed { - MLXRandom.seed(UInt64(seed)) - } - // ── Parse messages with multipart content support (for VLM images) ── var chatMessages: [Chat.Message] = [] var systemPromptText = "" @@ -3555,12 +3558,12 @@ func handleTextCompletion( prefillStepSize: config.prefillSize ) generateParams.prefill.progress = forwardPrefillProgress + // Same seeding as the chat path; see the comment there (#219). + let seed = samplingSeed(compReq.seed) + generateParams.seed = seed + if let seed { MLXRandom.seed(seed) } let params = generateParams - if let seed = compReq.seed { - MLXRandom.seed(UInt64(seed)) - } - await semaphore.wait() let slot = GenerationSlot(semaphore: semaphore) await stats.requestStarted() @@ -4033,6 +4036,13 @@ func checkStopSequences(_ text: String, stopSequences: [String], lookback: Int? return (String(text[text.startIndex.. UInt64? { + requested.map { UInt64(bitPattern: Int64($0)) } +} + /// Characters of already-checked text a streaming stop scan must look back over: the /// longest stop string, plus one because a chunk can merge with the previous character /// into a single grapheme. diff --git a/tests/SwiftLMTests/SamplingSeedTests.swift b/tests/SwiftLMTests/SamplingSeedTests.swift new file mode 100644 index 0000000..efd8fb7 --- /dev/null +++ b/tests/SwiftLMTests/SamplingSeedTests.swift @@ -0,0 +1,49 @@ +import XCTest +import Foundation +import MLX +import MLXLMCommon +@testable import SwiftLM + +// MARK: - The `seed` request field +// +// Two defects hid behind one line. `MLXRandom.seed(UInt64(seed))` seeded the *global* +// generator, but the samplers draw from their own `RandomState`, which is only seeded +// through `GenerateParameters.seed` — so the same seed produced different output on every +// call. And `UInt64(seed)` traps on a negative value, so `"seed": -1` took the whole +// server down with "Negative value is not representable". +final class SamplingSeedTests: XCTestCase { + + func testAbsentSeedStaysAbsent() { + XCTAssertNil(samplingSeed(nil), "no seed means entropy-seeded sampling, as before") + } + + func testNonNegativeSeedIsPassedThrough() { + XCTAssertEqual(samplingSeed(0), 0) + XCTAssertEqual(samplingSeed(42), 42) + XCTAssertEqual(samplingSeed(Int.max), UInt64(Int.max)) + } + + /// OpenAI's `seed` is a plain integer, so clients can and do send negative values. + /// They must map to *some* seed rather than crash the process. + func testNegativeSeedDoesNotTrap() { + XCTAssertEqual(samplingSeed(-1), UInt64(bitPattern: -1)) + XCTAssertEqual(samplingSeed(Int.min), UInt64(bitPattern: Int64(Int.min))) + XCTAssertNotEqual(samplingSeed(-1), samplingSeed(-2), "distinct seeds stay distinct") + } + + /// The seed must reach the sampler, not just the global generator: two samplers built + /// from the same parameters have to agree, and a different seed has to be able to + /// disagree. This is the property the server relies on for reproducible requests. + func testSeededParametersGiveReproducibleSampling() { + func sample(seed: UInt64?, draws: Int = 24) -> [Int] { + var params = GenerateParameters(temperature: 1.0, topP: 1.0) + params.seed = seed + let sampler = params.sampler() + // A flat distribution over a handful of tokens, so the draw is all RNG. + let logits = MLXArray([Float](repeating: 0, count: 64)).reshaped(1, 64) + return (0../dev/null +seeded_message() { chat "$SEEDED" | python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin)["choices"][0]["message"], sort_keys=True))'; } +S1=$(seeded_message); S2=$(seeded_message) +if [ -n "$S1" ] && [ "$S1" = "$S2" ]; then + pass "seeded sampling is reproducible ($(echo "$S1" | head -c 60))" +else + fail "same seed gave different output: $(echo "$S1" | head -c 60) vs $(echo "$S2" | head -c 60)" +fi + +log "Test 9b: a negative seed is accepted and the server survives it" +NEG_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$URL/v1/chat/completions" -H 'Content-Type: application/json' \ + -d '{"model":"x","messages":[{"role":"user","content":"hi"}],"max_tokens":3,"seed":-1}') +if [ "$NEG_CODE" = "200" ] && curl -sf "$URL/health" >/dev/null 2>&1; then + pass "negative seed answered 200 and /health still responds" +else + fail "negative seed: HTTP $NEG_CODE (000 = connection dropped), server alive: $(curl -sf "$URL/health" >/dev/null 2>&1 && echo yes || echo no)" +fi + log "═══════════════════════════════════════" log "Results: $PASS passed, $FAIL failed, $SKIP skipped, $TOTAL total" log "═══════════════════════════════════════"