Skip to content

synth: a data synthesizer — training data for traffic that doesn't exist yet - #14

Open
pradipta-lyzr wants to merge 14 commits into
open-gitagent:mainfrom
pradipta-lyzr:feat/data-synthesizer
Open

synth: a data synthesizer — training data for traffic that doesn't exist yet#14
pradipta-lyzr wants to merge 14 commits into
open-gitagent:mainfrom
pradipta-lyzr:feat/data-synthesizer

Conversation

@pradipta-lyzr

Copy link
Copy Markdown

The fourth inlet. capture() records a live agent, traces reads one that
already ran, Dataset.from_* loads what you have — all three need the traffic
to exist. This makes it: describe the task, point at a document, or amplify a
few real episodes, and a teacher model writes the rest.

Seeds × modes, mirroring backends × methods. Everything converges on
Trajectory and emit.py renders it into the shape the consumer takes, chosen
from the method's spec, never its name — so method="dpo" yields preference
pairs and method="more_plus" yields query-diverse paraphrase units.

format="otlp" emits OpenTelemetry GenAI spans that round-trip losslessly
through traces.from_otlp — verified on real generated data, tool calls and all.

Reachable from the SDK, shadowlm synth, POST /v1/synth, and a Synthesize tab
in the studio. 174 tests; every emitter is asserted by the code that consumes it.

Also fixes three things underneath: traces.py never extracted tool schemas,
MoRE+ routed on only the first phrasing in a unit, and tool-schema train/inference
skew is now stated rather than silent.

pradipta-lyzr and others added 14 commits August 3, 2026 23:13
… unit

Three fixes the synthesizer needs underneath it, each standing on its own.

traces._span_call hardcoded tools=None, so a Trajectory rebuilt from spans
never carried the tool definitions the spans were already reporting — and
to_dataset therefore never emitted a "tools" key. Read them from
gen_ai.request.tools, or OpenInference's indexed llm.tools.{i}.tool.json_schema,
wrapping bare function schemas.

more_plus.split_units built a unit's BM25 surrogate from grp[0] alone, so when
a unit holds several phrasings of one fact, rows 2..k contributed nothing to
routing — an expert reachable only by the wording that happened to come first.
Join the whole group.

Neither backend passes a dataset's tool schemas to the training chat template,
while chat() does pass them at inference. Whether trl can carry a tools column
is version-dependent and unverified here, so say it out loud rather than let
the skew pass silently — the convention this repo already holds for config a
backend can't honor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t yet

capture() records a live agent, traces reads one that already ran, and
Dataset.from_* loads what you already have. All three need the traffic to
exist. This makes it: describe the task, point at a document, or amplify a few
real episodes, and a teacher writes the rest.

Two orthogonal axes again, mirroring backends × methods. Seeds decide where
scenarios come from (task / document / episodes); modes decide what gets
written per scenario (conversation / preference / paraphrases). Any seed
composes with any mode. Everything converges on Trajectory — the same type
capture and traces produce — and emit.py renders that into the shape the
consumer takes, chosen from the method's *spec* and never its name.

Generation is taxonomy first, instances second. A teacher asked for variety in
one breath rewrites one example n times; a teacher asked to fill a named slot
in a scenario tree doesn't. That structure, not prompt wording, is the
anti-mode-collapse mechanism.

Two consequences of the architecture worth naming:

  - trajectory-GRPO learns from the spread between good and bad attempts, so
    the judge scores but does not filter for that format — gating it would
    throw away exactly the signal it needs.
  - an outcome is accepted or rejected whole. MoRE+ groups rows by fixed size,
    so dropping one row of a paraphrase unit would misalign every unit after
    it; since a conversation outcome is one row, that is the same rule
    everywhere.

Nothing is dropped silently: SynthReport reconciles exactly (generated == kept
+ invalid + duplicate + low-scoring + surplus) and report.balanced asserts it.
Teachers are duck-typed .chat(), so a frontier model, a local slm.load() model
and — later, for SDPO — the student itself are interchangeable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`shadowlm synth` with --dry-run, so you can see the resolved output shape
before spending a teacher call on it.

POST/GET /v1/synth follows the model-download pattern — a background thread
polled for status — rather than the training queue, since synthesis is teacher
calls and would otherwise hold the one training slot for the whole run. The
finished rows land in DatasetStore like any other dataset. Teacher API keys are
used for the run and never written to disk, unlike the HF token, which is
deliberately persisted.

Datasets gains a Synthesize tab beside Upload and Hugging Face: task, optional
grounding document, target method, and a teacher that is either an
OpenAI-compatible endpoint or a model already on this machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ples

synthesize_from_task.py is the cold start — no agent has run, so there is
nothing to capture and no traces to read. synthesize_from_doc.py is the
signature path: facts pulled from a document, each phrased several ways,
because MoRE+ routes on the question side and a fact asked about one way is an
expert nobody can reach.

CLAUDE.md gains the synth/ section and, while there, the traces.py and eval.py
entries the architecture notes had drifted past.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everything below was found by running the synthesizer against a real model.
A scripted fake answers the prompt you meant to write; a real one answers the
prompt you actually wrote.

Tool episodes failed 6/6. Three causes, all mine:

  - The OpenAI wire shape is four levels deep — messages > tool_calls >
    function > arguments — and the teacher miscounted the closing braces,
    emitting `"arguments":{"invoice_id":"5678"}}}]}}` and losing the whole
    episode to a JSON error. It also invented call ids it then failed to match.
    So ask for a shallow {"call": {name, args}} / {"role":"tool","result":…}
    pair and build the protocol in _wire_tool_calls, where the nesting and the
    ids are right by construction. The teacher writes the semantics; we write
    the wire format.

  - A reply cut off by the token budget left the outer object unclosed, so the
    object scan returned the *first message* instead: a dict that parses,
    carries no conversation, and reads as "the teacher said nothing useful".
    _parse_conversation now requires the shape, turning a silent dead end into
    a retry.

  - The judge scored only first-user-message against last-assistant-message, so
    an answer citing what a tool returned looked unsupported and was gated out.
    It now sees the whole lead-up — which is also the fix for any multi-turn
    episode, not just tool ones.

Offered a "tool" role with no tools defined, the teacher invented tool turns to
narrate its own reasoning ("Classifying urgency: urgent"). Don't offer the role
when there are no tools, and say why.

per_scenario now defaults by shape rather than to 4. GRPO and the MoRE methods
want depth — several attempts to compare, several phrasings to route by —
but four conversations about one scenario are four rewrites of one example.
Measured on the same workload: 4 scenarios → 7, wasted retries 5 → 1.

Also cap the turn count in the prompt: max_seq_length defaults to 2048, so a
sprawling episode would have its final assistant turn — the thing being trained
— truncated away at training time.

Tool episodes now land 6/6 with correct wire form, the OTLP round-trip is
lossless on real generated data, and document→MoRE+ produces exactly k rows per
fact with every unit routable by its keyword-style phrasing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ld ask

Leading the question prompt with "TASK: <what the assistant does>" made the
teacher perform the task instead of writing the customer's message. The
"question" came back as an assistant reply, "chosen" then answered that reply
in almost the same words, and the pair collapsed into three near-identical
strings that teach a model nothing. Both sides passed the not-equal check by
differing somewhere past the first two hundred characters.

So state the writing job first and demote the task to parenthetical context,
and reject a pair whose question and chosen answer overlap heavily — the
signature of the teacher having swapped roles again.

The reason this survived until a live run: no fake-teacher test exercised the
preference path at all, so renaming the prompt broke nothing visible. Covered
now, including the student-as-rejected pairing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ask for JSON

A pass over the feature with fresh eyes, plus a live run to check each fix.

Robustness — the expensive failure was losing a run's paid-for rows:

  - One teacher exception anywhere used to propagate through the thread pool
    and kill the whole run at whatever it cost. Now a failed job counts as
    invalid and the run continues; a circuit breaker trips after four
    consecutive failures so a dead endpoint stops the run early *with* its
    rows instead of burning a retry cycle per remaining job. Generation and
    judging get separate breakers — a generation failure must not cancel the
    scoring of rows already generated.
  - serve.start_synth loaded a local teacher (and resolved an HF episodes
    dataset) on the HTTP request thread; a cold 7B load held the POST for
    minutes. Both now happen on the background thread the run owns.

Accounting — two places the funnel quietly lied:

  - A GRPO group whose attempts all scored the same is dropped at emit (no
    spread, no signal), but report.kept still claimed its rows. New
    rejected_flat bucket; report.balanced now covers it. A live run promptly
    produced one: "4 flat-group", reconciled.
  - mean_score skipped rows scoring 0.0 (`if t.reward`), biasing the mean up
    exactly where low scores are legitimate. Now averages judge_score metrics.

Coverage and cost:

  - Document seeds ignored `avoid`: a top-up round re-extracted and
    re-planned the same facts, dedup rejected them all, and the run stalled
    without ever reading past where round one stopped. Facts are now cached
    per chunk and skipped once planned, so round two advances into the
    document.
  - The CLI's --per-scenario default of 4 silently overrode the shape-aware
    default (breadth for SFT, depth for GRPO/MoRE). It defaults to unset now,
    and --dry-run prints the resolved value.

Quality:

  - Student-as-rejected pairs are now judged on both sides; a pair where the
    student scores no worse than the teacher is dropped — it would train the
    DPO gap backwards. Teacher-corrupted rejects stay unjudged: flawed by
    construction, scoring them doubles judge cost for nothing.
  - OpenAI-compatible teachers ask for the server's JSON mode on
    JSON-shaped prompts, with one plain retry (remembered) for servers that
    reject response_format. Live effect: tool episodes went from 1 invalid +
    1 repaired to 0 and 0.
  - The paraphrase prompt now actually uses the style its metadata was
    already claiming; serve treats min_score=0 as "no gate", same as the CLI;
    to_groups' error named a parameter that no longer exists.
Without it wc -l undercounts by one — the report says 6 rows, the file
reports 5 — and anything appending to the file corrupts the final row.
serve, dev and demo run $(VENV)/bin/shadowlm; on a fresh clone that is just a
missing file, and make reports it as one. Point at make install / install-torch
instead, and mention that the server also runs straight from any environment
that already has the package.

check and gpu-test only need the interpreter, so they now bootstrap the venv
through the existing rule rather than failing the same way.
The wheel ships the compiled UI, so the built bundle is part of the feature,
not a local artifact.
An empty key was accepted silently and only surfaced as a raw provider 401
once the run was already underway — a message that says nothing about where
the key was supposed to go. Check it when the teacher is built instead, and
name the three places it can come from.

Only api.openai.com insists: vLLM and Ollama serve the same API without auth,
so a custom base_url stays keyless.
…ument

The studio sat at 0 for an entire run. on_progress fired once per round, and
round one does all the work — so the bar had nothing to show until everything
was already done, then jumped to 100%.

Generation was parallel, but ThreadPoolExecutor.map hands back the whole batch
at once, so there was no incremental signal even internally. run_jobs now
collects through as_completed while writing results into their submitted slot:
callers still get submission order (MoRE+ units are consecutive rows and must
stay that way) and get a tick per finished job.

on_progress becomes (done, total, phase) — planning, generating, judging,
kept — and fires from every phase including the leaf planning. The studio bar
tracks the live phase counter while a batch runs and the real kept count once
a round lands; the server keeps logging one line per round rather than one per
job, which would bury the report.

Document fact extraction ran one chunk at a time, so a long document spent its
whole extraction latency up front with the teacher idle. It now goes through
run_jobs like everything else. Default frontier parallelism 4 → 8.

Measured on the handbook document: longest silent stretch 7.1s → 5.2s, and
that remainder is one indivisible extraction call, now shown as a labelled
pulsing phase rather than a dead bar.
Three things stood between this and production, all of them about a feature
whose entire cost model is "call a paid API in a loop".

You could not see what a run cost. The provider reports usage on every
response and we discarded it. Tokens now come straight from that block into
the report — prompt and completion split out, and deliberately no price table,
because a hardcoded rate card goes stale and then lies about money. A teacher
that reports no usage (a local model) reads as zero rather than a guess.

You could not stop one. A 5,000-row run started by mistake had to be killed
with the server. synthesize() takes should_stop= and token_budget=; the studio
grows a cancel button and the CLI a --token-budget flag. Both keep the rows
already produced.

They gate generation only, and that distinction is the whole design: rows that
exist have already been paid for, so leaving them unscored fails them at the
judge gate and wastes everything spent making them. Scoring what was produced
costs a fraction of generating it. The test suite pins this — a generation
outage must still let judging finish.

The budget is honestly a throttle rather than a ceiling. Measured live, a
1,552-token budget landed at 8,587: in-flight parallel calls plus that
deliberate scoring. Said plainly in the docstring and the CLI help rather than
dressed up as a cap.

Studio runs were memory-only and unbounded — lost on restart, growing forever.
They now persist under work_root/synth/ like training jobs, with the log lines
per run and the runs themselves both capped, and a record still marked running
at startup is reported stopped, since its thread is gone.

Two smaller things a real provider will hand you: 429s now honour the server's
own Retry-After instead of our backoff curve, and a 400 naming
max_completion_tokens switches the parameter and retries, so reasoning models
work without configuration.
Only the docs collided, and only where upstream and this branch each added a
row describing an inlet — trace ingestion and synthesis are both real, so both
stay. Everything else merged clean; the suite is green at 293 tests.

Worth noting for the review: upstream independently fixed the worker's
backend_factory bug the same way this branch's sibling fix did, so
fix/worker-honors-backend-factory is now redundant and can be dropped.

Adopted from upstream on the way through: the login throttle, owner-only
settings/token files, the request body cap and the tar member vetting all now
sit under the synth routes too, which read their bodies through the same
capped helper.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant