Ster/Docs

Synthetic pairs

Synthetic pairs are a contrastive pair set Ster generated for itself instead of one somebody wrote by hand. ster pairs synthesize takes a one-sentence trait description and a count, generates on the route --generator picked, and writes a pa

Overview#

Synthetic pairs are a contrastive pair set Ster generated for itself instead of one somebody wrote by hand. ster pairs synthesize takes a one-sentence trait description and a count, generates on the route --generator picked, and writes a pair set plus a report of what it kept and what it threw away. It is a port of Wisent's synthetic pair generator onto Ster: the same prompts and the same cleaning rules, driven either by a local open-weight model on Ster's own runtime or by a hosted model reached through Brama.

A generated set is an ordinary pair set. It loads, trains, optimizes, evaluates, and inspects exactly like a hand-written one, and it is subject to the same invariants.

The two generator routes#

Steering needs hidden states, so every command that reads or applies a direction runs on a local open-weight model. Writing pair text reads no activations at all, which makes the writer of the training data the one model in Ster that may be hosted — and a hosted model at Wisent is reached through Brama, never through a provider SDK or a raw API key. --generator picks between the two routes, local and brama, and names one every time; nothing is assumed.

  • `local` — drives Ster's own runtime. It needs --model, honours --revision, --device, --precision and --chat-template, and generates exactly the way ster generate does. Without a model it refuses as pairs synthesize with --generator local requires --model.
  • `brama` — writes with a hosted model through the gateway. It takes --generator-model in place of the model arguments: no weights are loaded, no device is resolved, and nothing is downloaded. Without a route it refuses as pairs synthesize with --generator brama requires --generator-model. The base is BRAMA_URL, which is required — there is no built-in gateway, and an unset or empty value is refused as BRAMA_URL is unset or empty; export the Brama gateway base Ster should call — and the credential is BRAMA_BEARER. Ster never reads Skarbiec itself: it reads BRAMA_BEARER from its own environment, and nothing puts it there for you. There is no launcher script in the Ster repository, no wrapper on the fleet, and no service unit that exports it, and Ster Desktop runs ster request with the environment it inherited without setting a variable of its own. You export it before the run. The value to export is Ster's own client identity, never another product's: Ster is a declared consumer of brama in Stado's service directory, and Skarbiec holds a client identity minted for the consumer ster whose capability is call:brama#<route>, which is the grant Brama resolves by introspection when it meets a bearer its own start did not preload. Mint it where the gateway will look, not where you are standing: Brama introspects the vault on its own host, so a grant minted in a workstation's vault authenticates against that workstation and is still answered 401 unauthenticated by the gateway, which never asks it. The command is stado host vault-token-mint <gateway-host> ster --capabilities 'call:brama#<route>' --audience ster --raw-token, which goes through Stado's managed channel rather than ssh and prints the bearer and nothing else, for a direct pipe into a process-only variable. Neither Stado nor you should put it in a file or an argv.

--chat-template belongs to the local route and defaults to auto, which asks the generator through the checkpoint's own conversation format when it publishes one. Every prompt in this loop is an instruction — derive an opposite trait, write a question, answer it in a voice — and an instruct checkpoint spoken to without its markers answers a pair request with instructions about answering pair requests: "Step 3: Make sure your emojis are visually appealing" rather than the pair text that was asked for. Synthesis is the first step of the funnel and everything downstream inherits what it writes, so the default matters more here than anywhere. --generator brama accepts the flag and ignores it: the gateway is already a chat API, so the markers are the provider's business (chat template).

Each generation on the hosted route is one POST {BRAMA_URL}/v1/chat/completions, carrying Authorization: Bearer … and content-type: application/json, with a body of exactly model, messages (one user message), max_tokens, and temperature. Brama refuses unknown fields by name, so nothing else may travel — which is why --top-p and --seed stay behind. A hosted run is therefore not seed-reproducible: the provider behind the route owns its sampler, and the running deduplicator, not the seed, is what suppresses repeated draws.

--generator-model is a name in Brama's model-name vocabulary, which is exactly four things: a declared alias, of which wisent-backend/chat/primary is Wisent's own chat alias and wisent-backend/chat/fallback its sibling; the delegation alias best; a canonical provider/model route such as anthropic/claude-3-5-haiku-latest; or a selector such as any or task:<name>. best and the selectors additionally require the three agent HMAC headers computed over the exact raw body, and Ster sends only a bearer and constructs no such signature, so a Ster run uses a declared alias or a canonical route. A bare vendor name such as gpt-4o is neither, and Brama refuses it with model must be a canonical provider/model route or a supported selector. An empty route never reaches the wire: the generator model must be a Brama alias, a canonical provider/model route, or a selector.

The environment is checked before the first call — BRAMA_BEARER is unset or empty; export Ster's own Brama bearer before running this command, and BRAMA_URL must be an https:// base or an explicit http:// loopback address, because Brama answers plain http elsewhere with 426 secure_transport_required, loopback unencrypted being how a developer runs Brama locally. Two of the gateway's bounds are then duplicated locally, in Brama's own words, because a run that breaches them is wrong on every pair rather than one: max_tokens must be between one and 32768 and temperature must be finite and between zero and 2. Everything the gateway itself refuses arrives verbatim, as brama refused the completion: {status} {message} read out of Brama's {"error": {…}} envelope — for example brama refused the completion: 401 unauthorized — with brama refused the completion with {status} and a body that is not its error envelope: {excerpt} for a body that is not that envelope.

The order it runs#

  1. Derive the opposite trait. One generation asks the model for the opposite of --trait, described in one sentence and specific about words, style, and tone. --opposite supplies the text and skips this step. An empty answer falls back to neutral and plain.
  2. Generate a question. One fixed instruction produces the short user question the pair answers.
  3. Answer it twice. The same question is answered once in the trait's voice and once in the opposite's, through the same prompt shape both times, one or two sentences each.
  4. Fold the question into both sides. Each side is stored as Question: <question> and Answer: <answer> on the next line, the shape the README's own example uses. Folding it in identically is what keeps the two sides matched on topic, wording, and length, so the only difference left between them is the trait.
  5. Keep it, or drop it. A side the model returned empty, a side that reads as a refusal, or a pair that is a near-duplicate of one already kept ends the attempt; anything else is kept.

The attempt budget is --count times --retry-multiplier, which the caller states, and the loop stops the moment --count pairs have survived rather than generating a batch and cleaning it afterwards. Every attempt prints one line to stderr — synthesizing pair 3/20 (attempt 7) — which is what the desktop streams while a run is in flight.

Why the opposite is derived first#

The negative side has to be a real answer that lacks the trait, not the absence of an answer. Asking the model to describe the opposite trait first turns "not calm" into a specific instruction about words, style, and tone, and that instruction is then used exactly like the positive one: same question, same prompt shape, opposite persona. That is what makes the two sides differ along the trait and nothing else. A caller who already knows the opposite states it with --opposite and saves one generation per run.

Refusal repair#

Safety-tuned open-weight models answer "write a response that lacks this trait" with a refusal surprisingly often, and a refusal is a useless negative: it differs from the positive along the refusal axis rather than the trait axis. Detection is lexical — ten weighted phrasing families, no second model call — and a side scoring at or above the stated --refusal-threshold reads as a refusal.

  • A refusing negative is asked again exactly once, with a repair instruction that asks for a single concise response exemplifying the undesired trait, fictional, safe, and non-actionable. refusal_retries counts these.
  • If the repair comes back empty or still reads as a refusal, the pair is dropped and rejected_refusals counts it. There is no loop: one repair, then the attempt ends.
  • A refusing positive is dropped immediately with no repair attempt. The trait itself is what the model declined to roleplay, so asking again would refuse again.

Why the deduper drops a pair#

Sample one model a hundred times for one trait and a large share of the answers are paraphrases of each other. Paraphrases are worse than useless for training a direction: they weight one phrasing heavily and make the training margin look better than it is. Each candidate is fingerprinted with a 64-bit SimHash over both normalized sides, bucketed into the stated --dedupe-bands bands, and compared against the pairs already kept. A pair identical after normalization, or within the stated --dedupe-bits Hamming distance of an earlier kept pair, is dropped and rejected_duplicates counts it.

First occurrence wins, which decides which of two near-identical pairs survives: the earlier one. During synthesis the survivor is whichever pair the model produced first; in `ster pairs inspect`, where nothing is dropped and both pairs are reported, the survivor is whichever pair comes first in the file and the later one carries the duplicate flag.

Why temperature zero is refused#

Every prompt in the loop is a constant: one fixed question instruction, one prompt shape per side. A greedy decoder — Ster's own argmax path at or below zero temperature, and any hosted sampler asked for the same — would return the identical question and the identical two answers on every attempt, and the whole run would deduplicate down to one pair. The reason belongs to the loop rather than to a runtime, so the refusal holds for either generator, and synthesis refuses before generating anything: synthesis requires a temperature above zero; argmax generation repeats a single prompt. The defaults are --temperature 0.9 and --top-p 0.95.

Sampling also has to vary between calls, not only within one. On the local route Ster builds a fresh sampler from --seed for every generation call, so one fixed seed would replay one continuation for one prompt and collapse the run the same way; the seed therefore advances by one on every model call, and a single --seed still reproduces the whole local run. Nothing seed-like travels to Brama, so a hosted run is not reproducible from --seed at all and relies on the provider's own sampler plus the running deduplicator.

The report#

FieldMeaning
generatorWhat wrote the pairs: local:<model id> for Ster's own runtime, brama:<route> for the gateway. First field in the report, so a set can be read against its writer.
trait_nameThe label written into the set: --trait-name, or the whole trait description when none is given.
trait_descriptionThe --trait text, verbatim.
oppositeThe opposite-trait description every negative was generated from, whether derived or supplied.
requested--count.
attemptsAttempts consumed: requested when nothing was rejected, at most requested times --retry-multiplier.
keptPairs written. Below requested when the attempt budget ran out first.
rejected_emptyAttempts where the model returned nothing for the question or for one of the two answers.
rejected_refusalsAttempts dropped because the positive read as a refusal, or the negative still read as one after its single repair.
rejected_duplicatesAttempts dropped as a near-duplicate of a pair already kept.
refusal_retriesNegatives that were asked again. A repair that succeeded is counted here and nowhere else.
diversityunique_unigrams, unique_bigrams, avg_jaccard, mean_simhash_hamming, and min_simhash_hamming over the generated questions: the answers inherit their variety from the question that produced them.

Every attempt ends in exactly one of four outcomes, so kept, rejected_empty, rejected_refusals, and rejected_duplicates sum to attempts. refusal_retries is not one of them; it counts a repair, which may still end in a kept pair. A low kept against a high attempts is the signal to read the counters: mostly duplicates means the trait description is too narrow or the temperature too low, mostly refusals means the model will not roleplay the opposite.

Boundary#

The generator is whichever route --generator picked: Ster's own runtime, on the model --model names and the device --device selects, or a hosted model reached through Brama. Pair quality therefore follows whichever generator wrote the set — the opposite trait, the question, and both answers are all one model's output, local or hosted — and generator in the report names which one it was. ster pairs inspect is how the result is checked rather than trusted.

Hosted model routing remains Brama's: the hosted writer is reached through the gateway and never through a provider SDK or a raw API key, and the bearer comes from the environment the operator exported it into rather than from Ster reading Skarbiec. Release delivery is Stado's and works: Ster installs through the stado-release recipe in its own .wisent-release.json. Fleet placement is Stado's in principle and declares nothing for Ster. Credentials are Skarbiec's, and Skarbiec now holds a ster client identity for this one route, but no launcher fetches it, so reaching the gateway still depends on a person exporting the bearer. Steering itself stays local — only the writer of the training data may be hosted, because a pair is plain text and no hidden state is read to produce it. Synthesis writes exactly one file, the pair set at --output, and validates it first, so a run that kept nothing refuses as pair set <PATH> contains no pairs instead of writing a document no loader would accept.