A changed sentence can be expected behavior without being a useful answer.
Imagine a team evaluating a support assistant that drafts a password-reset explanation. With the same prompt, one run says “open the account page” and another says “visit account settings.” The wording differs, so the reviewer wants to know what changed. First compare the exact prompt, model/version, decoding settings, and any retrieved context. Then inspect the next-token scores and the sampling rule rather than guessing from the finished paragraph.
We will use five artificial logits for one generation step. A real model has a much larger vocabulary and context-dependent scores. This small set makes truncation and renormalization visible; it is not a model of answer quality.
- Observation
- The two outputs use different wording; correctness has not yet been checked.
- Competing explanations
- Sampling, changed model or prompt, nondeterministic serving, or a change in retrieved context.
- First evidence
- Record model/version, full input, decoding parameters, seed if supported, and the source material used to answer.
Logits are relative scores; softmax turns them into a distribution.
For this step, let five candidate tokens have logits 4, 3, 2, 1, and 0. A logit is an
unnormalized model score, not a percent. At temperature 1, softmax assigns each score exp(logit) / Σ exp(logit). Subtracting the largest logit before exponentiating keeps the arithmetic numerically
stable and does not change the probabilities.
The resulting approximate probabilities are 0.636, 0.234, 0.086, 0.032, and 0.012. They sum to 1 before any truncation. The model's largest score corresponds to “the,” but the rest of the distribution describes alternatives available at this single position, not complete sentence probabilities.
| Candidate | Logit | Approx. probability |
|---|---|---|
| the | 4 | 0.636 |
| a | 3 | 0.234 |
| our | 2 | 0.086 |
| this | 1 | 0.032 |
| one | 0 | 0.012 |
Each rule changes the candidate set before a token is drawn.
Greedy selects the top candidate, so its probability becomes 1 after truncation. It is deterministic for fixed scores and tie-breaking, though a full service can still vary because of model or serving changes.
Top-k retains the k highest-scoring tokens. With k = 3, the unnormalized retained mass is approximately 0.636 + 0.234 + 0.086 = 0.956. Renormalizing gives about 0.665, 0.245, and 0.090. The lower two candidates cannot be chosen at this step.
Top-p, also called nucleus sampling, sorts candidates from most to least likely and retains the shortest prefix whose cumulative probability reaches p. At p = 0.8, the first two sum to 0.870, so only those two remain; after renormalization their probabilities are about 0.731 and 0.269. The number of candidates changes with the shape of the distribution.
Highest score only; no random choice at this position.
Fixed count; retain 0.956 of original mass, then rescale to 1.
Smallest prefix reaching 0.8; retain 0.870, then rescale.
Temperature reshapes scores before top-k or top-p filters them.
For positive temperature T, softmax uses logit / T. Below 1, gaps become
larger and probability concentrates on the highest scores. Above 1, gaps shrink and the
distribution flattens. At T = 1 the original softmax is unchanged. Temperature does not
create new candidates or make weak evidence reliable.
Because top-p looks at cumulative probability, changing temperature can change how many candidates cross the p threshold. With top-k, the retained count remains k, though the probabilities within that set still change. Order remains the same for finite positive T when logits are unchanged.
Change one setting and watch the retained mass get renormalized.
The simulator uses the same five fixed logits from the table. The seeded draw exists so the exercise can be replayed; its small linear-congruential generator is not suitable for security, and a seed alone may not reproduce outputs across different model versions or serving systems.
| Token | Logit | Retained | Final probability |
|---|---|---|---|
| the | 4 | yes | 0.731 |
| a | 3 | yes | 0.269 |
| our | 2 | no | 0.000 |
| this | 1 | no | 0.000 |
| one | 0 | no | 0.000 |
With this toy sampler and seed 7, the selected token is shown above. This is a single draw, not a claim about which continuation is best.
Keep sorting, truncation, and renormalization explicit.
The examples apply softmax stably by subtracting the maximum scaled logit, choose a retained prefix, then normalize only its weights. Both languages use the same artificial candidates and include input guards. They illustrate one distribution step, not a full text-generation engine.
Both snippets use the five illustrative logits and the same top-p cutoff.
export type Candidate = { token: string; logit: number };
export type Strategy = 'greedy' | 'top-k' | 'top-p';
export type RankedCandidate = { token: string; probability: number; retained: boolean };
export const exampleCandidates: Candidate[] = [
{ token: 'the', logit: 4 },
{ token: 'a', logit: 3 },
{ token: 'our', logit: 2 },
{ token: 'this', logit: 1 },
{ token: 'one', logit: 0 }
];
export function distribution(
candidates: Candidate[],
strategy: Strategy,
options: { temperature: number; k: number; p: number }
): RankedCandidate[] {
const { temperature, k, p } = options;
if (!Number.isFinite(temperature) || temperature <= 0)
throw new RangeError('temperature must be > 0');
if (!Number.isInteger(k) || k < 1) throw new RangeError('k must be an integer >= 1');
if (!Number.isFinite(p) || p <= 0 || p > 1) throw new RangeError('p must be in (0, 1]');
if (candidates.length === 0) return [];
if (candidates.some((candidate) => !Number.isFinite(candidate.logit))) {
throw new TypeError('candidate logits must be finite');
}
const sorted = candidates
.map((candidate, index) => ({ ...candidate, index }))
.sort((a, b) => b.logit - a.logit || a.index - b.index);
const maxLogit = sorted[0].logit;
const weights = sorted.map((candidate) => Math.exp((candidate.logit - maxLogit) / temperature));
const total = weights.reduce((sum, weight) => sum + weight, 0);
const probabilities = weights.map((weight) => weight / total);
let retainedCount = sorted.length;
if (strategy === 'greedy') retainedCount = 1;
if (strategy === 'top-k') retainedCount = Math.min(k, sorted.length);
if (strategy === 'top-p') {
let cumulative = 0;
retainedCount = 0;
for (const probability of probabilities) {
cumulative += probability;
retainedCount += 1;
if (cumulative >= p) break;
}
}
const retainedWeight = weights.slice(0, retainedCount).reduce((sum, weight) => sum + weight, 0);
return sorted.map((candidate, index) => ({
token: candidate.token,
probability: index < retainedCount ? weights[index] / retainedWeight : 0,
retained: index < retainedCount
}));
}
// A seeded sampler is useful for a reproducible teaching trace, not for secrets or production sampling.
export function sampleWithSeed(candidates: RankedCandidate[], seed: number): string {
if (!Number.isInteger(seed) || seed < 0 || seed > 0xffffffff)
throw new RangeError('seed must be uint32');
const total = candidates.reduce((sum, candidate) => sum + candidate.probability, 0);
if (total <= 0) throw new RangeError('distribution must have positive mass');
const random = ((Math.imul(seed >>> 0, 1664525) + 1013904223) >>> 0) / 0x100000000;
let cumulative = 0;
for (const candidate of candidates) {
cumulative += candidate.probability / total;
if (random < cumulative) return candidate.token;
}
return candidates.at(-1)!.token;
}
package main
import (
"errors"
"fmt"
"math"
"sort"
)
type Candidate struct {
Token string
Logit float64
}
type RankedCandidate struct {
Token string
Probability float64
Retained bool
}
func Distribution(candidates []Candidate, strategy string, temperature float64, k int, p float64) ([]RankedCandidate, error) {
if math.IsNaN(temperature) || math.IsInf(temperature, 0) || temperature <= 0 {
return nil, errors.New("temperature must be finite and greater than zero")
}
if k < 1 {
return nil, errors.New("k must be at least one")
}
if math.IsNaN(p) || p <= 0 || p > 1 {
return nil, errors.New("p must be in (0, 1]")
}
if strategy != "greedy" && strategy != "top-k" && strategy != "top-p" {
return nil, errors.New("strategy must be greedy, top-k, or top-p")
}
for _, candidate := range candidates {
if math.IsNaN(candidate.Logit) || math.IsInf(candidate.Logit, 0) {
return nil, errors.New("candidate logits must be finite")
}
}
sorted := append([]Candidate(nil), candidates...)
sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].Logit > sorted[j].Logit })
if len(sorted) == 0 {
return []RankedCandidate{}, nil
}
maxLogit := sorted[0].Logit
weights := make([]float64, len(sorted))
var total float64
for i, candidate := range sorted {
weights[i] = math.Exp((candidate.Logit - maxLogit) / temperature)
total += weights[i]
}
retained := len(sorted)
switch strategy {
case "greedy":
retained = 1
case "top-k":
if k < retained {
retained = k
}
case "top-p":
var cumulative float64
retained = 0
for _, weight := range weights {
cumulative += weight / total
retained++
if cumulative >= p {
break
}
}
}
var keptWeight float64
for _, weight := range weights[:retained] {
keptWeight += weight
}
result := make([]RankedCandidate, len(sorted))
for i, candidate := range sorted {
result[i] = RankedCandidate{Token: candidate.Token, Retained: i < retained}
if i < retained {
result[i].Probability = weights[i] / keptWeight
}
}
return result, nil
}
func main() {
candidates := []Candidate{{"the", 4}, {"a", 3}, {"our", 2}, {"this", 1}, {"one", 0}}
for _, strategy := range []string{"greedy", "top-k", "top-p"} {
distribution, err := Distribution(candidates, strategy, 1, 3, 0.8)
if err != nil {
panic(err)
}
fmt.Println(strategy)
for _, candidate := range distribution {
fmt.Printf(" %-5s retained=%-5t p=%.3f\n", candidate.Token, candidate.Retained, candidate.Probability)
}
}
}
Decoding settings describe selection; evaluation establishes usefulness.
Greedy, top-k, and top-p answer how to choose a candidate from model scores. They do not prove the prompt is well-formed, the scores reflect the intended task, retrieved material is trustworthy, or a response is correct. A lower-entropy output can be consistently wrong; a varied output can still be well-grounded.
When debugging output differences, preserve a reproducible record: exact prompt and context, model/version, decoding settings, seed if the interface supports it, and output. Then evaluate each answer against explicit task criteria and cited evidence. If the same settings yield a different result, investigate version changes, parallel execution, tie-breaking, and service-level nondeterminism instead of assuming that the sampling parameter is the only cause.
- Holtzman et al., “The Curious Case of Neural Text Degeneration” — nucleus sampling and probability-mass truncation.
- Hendrycks et al., “The Whole Truth and Nothing But the Truth” — factuality evaluation as a separate problem from fluent generation.