Skip to content

feat: curate records into trainable datasets (SFT/preference/rollout export) (#96)#111

Merged
dcfocus merged 1 commit into
lance-format:mainfrom
dcfocus:feat/issue-96-curate-export
Jun 27, 2026
Merged

feat: curate records into trainable datasets (SFT/preference/rollout export) (#96)#111
dcfocus merged 1 commit into
lance-format:mainfrom
dcfocus:feat/issue-96-curate-export

Conversation

@dcfocus

@dcfocus dcfocus commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds the downstream half of the post-training pipeline (#96): curate stored ContextRecords into a lifecycle-correct, deduped/decontaminated cut and export it as SFT / preference / RL-rollout JSONL plus a reproducible manifest.

Independent of the other open PRs — branches from main.

This is the first slice the issue calls for ("first implementation can target JSONL plus a manifest"). See Scope below for what's intentionally deferred.

Core (lance-context-core::export)

  • Curation (selection only — never deletes/tombstones): keeps default-visible records and additionally drops contradicted; optional min_reward threshold (rejection sampling / Best-of-N); semantic dedup by cosine distance; decontamination against a holdout-embedding set.
  • Grouping/aggregation by session_id / run_id / tenant / source / bot_id / external_id prefix, with deterministic ordering by (created_at, id).
  • Three export shapes:
    • SftExample — ordered messages (also the rejection-sampling target via min_reward).
    • PreferenceExamplepaired chosen/rejected (DPO/SimPO/ORPO), unpaired binary label (KTO), or ranked N-way (judge/RULER).
    • RolloutExample — prompt + rewarded responses with group_id (GRPO/RLVR groups), reward, reward_source.
  • Provenance (context URI, version, record/external ids, tenant/source/bot/session/run, created_at range) and an ExportManifest (version, task, group_by, schema version, curation params, filters, created_at range, source record ids, per-stage counts).
  • ContextStore::export_training(config, output_path) streams JSONL line-by-line to disk, writes a sibling <path>.manifest.json, and pins to a dataset version (time-travel) with restore-after.

Field conventions (documented on the module): reward/preference signals live in each record's metadatareward, reward_source, group_id, label (chosen/rejected), rank. Messages are role + text_payload.

Python

manifest = ctx.export_training(
    "training/sft.jsonl",
    task="sft",                 # "sft" | "preference" | "rollout"
    group_by="session_id",
    filters={"tenant": "acme"},
    min_reward=0.7,             # rejection sampling
    dedup_threshold=0.02,
    version=ctx.version(),      # reproducible cut
)

Tests

  • core (12): SFT grouping + ordering, rejection sampling, all three preference forms, rollout groups, dedup, decontamination, contradicted filtering, version pinning + restore, reproducibility, external-id-prefix grouping, manifest sibling file.
  • python (8): mirrors the surface (SFT, rejection sampling, paired/unpaired preference, rollout, dedup, reproducibility, invalid-task error).

README gains a curate + export example.

Checks

  • cargo test -p lance-context-core --lib — 59 passed
  • cargo fmt --all -- --check, cargo clippy --workspace --all-targets -- -D warnings — clean
  • ruff format --check, ruff check, pyright — clean
  • test_export_training.py — 8 passed

Acceptance criteria (#96)

  • Deduped/decontaminated, lifecycle-correct, versioned curated cut
  • SFT JSONL grouped by session + rejection-sampling variant filtered by a reward threshold
  • Preference JSONL in paired / unpaired (KTO) / N-way ranked forms
  • RL rollout rows with prompt, response(s), reward/reward_source, optional group_id, multi-turn trajectory metadata
  • Manifest with version, curation params, filters/selectors, schema version, created_at range, source record ids
  • Streaming/chunked export (line-by-line writes; full JSONL never materialized)
  • Tests cover dedup/decontamination, lifecycle filtering, version pinning, grouping order, reproducibility
  • Namespace partial-selector fan-out — deferred (see below)

Scope / deferred follow-ups

Per the issue's own audit, these are intentionally out of this first slice and called out for follow-up PRs:

  • Namespace partial-selector fan-out (phase-2 — needs cross-partition read design; the audit flags it as new work).
  • User-supplied enrichment hooks (reward/verifier/judge/PII/summarization callbacks across the FFI boundary).
  • Parquet/Arrow output (JSONL first, "once the schema stabilizes").
  • REST POST /exports/training endpoint.

Note: curation is append-only selection (records are filtered for the cut, never deleted/tombstoned), matching the audit's guidance not to conflate curation with record removal.

Closes #96

…export) (lance-format#96)

Adds the downstream half of the post-training pipeline: curate stored
`ContextRecord`s into a lifecycle-correct, deduped/decontaminated cut and
export it as SFT / preference / RL-rollout JSONL plus a reproducible
manifest.

Core (`lance-context-core::export`):
- Curation (selection only — never deletes): keeps default-visible records
  and additionally drops `contradicted`; optional `min_reward` threshold
  (rejection sampling / Best-of-N); semantic dedup by cosine distance;
  decontamination against a holdout-embedding set.
- Grouping/aggregation by `session_id` / `run_id` / `tenant` / `source` /
  `bot_id` / `external_id` prefix, with deterministic ordering by
  `(created_at, id)` for reproducibility.
- Three export shapes:
  - `SftExample` — ordered messages (doubles as the rejection-sampling
    target via `min_reward`).
  - `PreferenceExample` — `paired` chosen/rejected (DPO/SimPO/ORPO),
    `unpaired` binary label (KTO), or `ranked` N-way (judge/RULER).
  - `RolloutExample` — prompt + rewarded responses with `group_id`
    (GRPO/RLVR groups), `reward`, and `reward_source`.
- Shared `Provenance` (context URI, version, record/external ids, tenant,
  source, bot/session/run, created_at range) and an `ExportManifest`
  (version, task, group_by, schema version, curation params, filters,
  created_at range, source record ids, per-stage counts).
- `ContextStore::export_training(config, output_path)` streams JSONL
  line-by-line to disk, writes a sibling `<path>.manifest.json`, and pins
  to a dataset `version` (time-travel) with restore-after.

Field conventions (documented): reward/preference signals live in each
record's `metadata` — `reward`, `reward_source`, `group_id`, `label`
(`chosen`/`rejected`), `rank`. Messages are `role` + `text_payload`.

Python: `Context.export_training(output_path, task=..., group_by=...,
preference_form=..., filters=..., dedup_threshold=..., min_reward=...,
decontaminate_against=..., version=...)` returning the manifest dict.

Tests: core (12) — SFT grouping/order, rejection sampling, all three
preference forms, rollout groups, dedup, decontamination, contradicted
filtering, version pinning + restore, reproducibility, external-id-prefix
grouping, manifest sibling file; python (8) mirroring the surface. README
gains a curate+export example.

Scope: first slice per the issue (JSONL + manifest). Deferred to
follow-ups (noted in the issue): namespace partial-selector fan-out
(phase-2), user-supplied enrichment hooks (reward/judge/PII), Parquet/Arrow
output, and the REST endpoint.

Closes lance-format#96
@dcfocus dcfocus merged commit 8ae3896 into lance-format:main Jun 27, 2026
9 checks passed
dcfocus added a commit to dcfocus/lance-context that referenced this pull request Jun 27, 2026
…format#103)

Follow-up to lance-format#96. Adds a group-disjoint, reproducible train/eval split to
the export path so eval scores aren't inflated by conversation leakage.

- `SplitConfig { eval_fraction, by, seed }` on `ExportConfig`. Each record
  is assigned to train or eval by a stable hash (FNV-1a + MurmurHash3
  fmix64 finalizer) of its `by` grouping key plus `seed`, so:
  - no group (default `session_id`, configurable to run_id / tenant /
    source / bot_id / external-id prefix) spans both sides, and
  - the same seed reproduces the identical partition across runs and
    platforms (the hash is fully defined, not `DefaultHasher`).
- When `split` is set, `export_training` writes `<path>.train.<ext>` and
  `<path>.eval.<ext>`, each with its own manifest recording the split
  params (`eval_fraction`, `by`, `seed`, `side`) and the complementary
  output path. The returned manifest is the train side.
- Curation (dedup/decontam/lifecycle) runs once before the split, so the
  two compose: decontamination drops train rows near eval, the split keeps
  groups disjoint.

Python: `ctx.export_training(..., split={"eval_fraction": 0.1,
"by": "session_id", "seed": 42})`.

Tests: core (determinism, group-disjointness, fraction-within-tolerance,
split manifests + complement path) and python (split disjointness +
determinism). README updated.

Stacked on lance-format#96 (PR lance-format#111).

Closes lance-format#103
dcfocus added a commit that referenced this pull request Jun 27, 2026
…112)

## Summary

Follow-up to #96: adds a **group-disjoint, reproducible train/eval
split** to the export path, so eval scores aren't inflated by
conversation/session leakage across the boundary. Closes #103.

> **Stacked on #111 (#96).** Review/merge #111 first; until then this PR
shows both commits (the split commit is last). The diff reduces to just
#103 once #111 lands.

## What it does

- `SplitConfig { eval_fraction, by, seed }` on `ExportConfig`. Each
record's side is decided by a **stable hash** (FNV-1a + MurmurHash3
`fmix64` finalizer) of its `by` grouping key + `seed`, so:
- **no group** (default `session_id`; also `run_id` / `tenant` /
`source` / `bot_id` / external-id prefix) spans both sides, and
- the **same seed reproduces the identical partition** across
runs/platforms (the hash is fully defined — not `DefaultHasher`).
- With `split` set, `export_training` writes `<path>.train.<ext>` and
`<path>.eval.<ext>`, each with its **own manifest** recording the split
params (`eval_fraction`, `by`, `seed`, `side`) and the **complementary
output path**. The returned manifest is the train side.
- Curation (dedup/decontam/lifecycle from #96) runs **once before** the
split, so the two compose: decontamination drops train rows near eval;
the split keeps groups disjoint.

## Python

```python
ctx.export_training(
    "training/sft.jsonl",
    task="sft",
    split={"eval_fraction": 0.1, "by": "session_id", "seed": 42},
)
# -> training/sft.train.jsonl + training/sft.eval.jsonl (+ manifests)
```

## Tests

- **core** (3): determinism (same seed → identical files),
group-disjointness, fraction-within-tolerance, and split manifests with
complement path.
- **python** (2): split disjointness + determinism.

README updated.

## Checks

- `cargo test -p lance-context-core --lib` — 62 passed
- `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets
-- -D warnings` — clean
- `ruff format --check`, `ruff check`, `pyright` — clean
- `test_export_training.py` — 10 passed

## Acceptance criteria (#103)

- [x] Split into train/eval by a grouping key with a fixed seed;
re-running yields the identical partition
- [x] No group (e.g. `session_id`) appears in both train and eval
- [x] Each output carries a manifest recording split params
- [x] Tests cover determinism, group-disjointness, and fraction accuracy
within tolerance

Optional follow-up (noted in the issue): also emit the eval side in
#98's labeled query-set format so retrieval-eval reuses the same
artifact — deferred to keep this focused and avoid coupling to the
(separate) #98 PR.

Closes #103
dcfocus added a commit to dcfocus/lance-context that referenced this pull request Jun 27, 2026
)

Follow-up to lance-format#96. Adds an optional `<output_path>.stats.json` report so a
training cut is auditable at a glance without loading the JSONL.

- `ExportConfig.emit_stats` gates a stats artifact computed during the
  existing export pass (no extra dataset re-materialization).
- `ExportStats` covers:
  - counts: examples, by `role`, by `source`, by `tenant`;
  - token stats (min/median/p95/max/mean) from
    `state_metadata.tokens_used`, falling back to a whitespace word-count
    proxy, with a `source` field (`tokens_used` / `length_proxy` / `mixed`);
  - grouping: `num_groups` + records-per-group distribution;
  - curation accounting: records excluded by reason (lifecycle, reward
    threshold, dedup, decontamination), derived from the curation counts;
  - reward distribution and `reward_source` breakdown (rollouts), plus the
    preference form.
- With a train/eval split, each side gets its own `.stats.json`.

Python: `ctx.export_training(..., emit_stats=True)` writes the sibling
report; read it from `<output_path>.stats.json`.

Tests: core (role/source/tenant counts, token stats + length-proxy
fallback, lifecycle exclusions, rollout reward distribution, flag gating)
and python (stats contents + flag gating). README updated.

Stacked on lance-format#96 (PR lance-format#111) and lance-format#103 (PR lance-format#112).

Closes lance-format#104
dcfocus added a commit that referenced this pull request Jun 27, 2026
## Summary

Follow-up to #96: emits an optional **dataset statistics report**
(`<output_path>.stats.json`) alongside each export so a training cut is
auditable at a glance — without loading the JSONL. Closes #104.

> **Stacked on #111 (#96) and #112 (#103).** Review/merge those first;
until then this PR shows their commits too (the stats commit is last).

## What it does

- `ExportConfig.emit_stats` gates a stats artifact computed **during the
existing export pass** (no extra dataset re-materialization).
- `ExportStats` covers everything the issue asks for:
  - **counts**: examples, by `role`, by `source`, by `tenant`;
- **token stats** (min/median/p95/max/mean) from
`state_metadata.tokens_used`, falling back to a whitespace word-count
proxy, with a `source` field (`tokens_used` / `length_proxy` / `mixed`);
  - **grouping**: `num_groups` + records-per-group distribution;
- **curation accounting**: records excluded by reason (lifecycle, reward
threshold, dedup, decontamination), derived from the curation counts;
- **reward distribution** + `reward_source` breakdown (rollouts), plus
the preference form.
- With a train/eval split (#103), each side gets its own `.stats.json`.

## Python

```python
ctx.export_training("training/sft.jsonl", task="sft", emit_stats=True)
# -> training/sft.jsonl.stats.json
```

## Tests

- **core** (4): role/source/tenant counts + token stats + lifecycle
exclusions, length-proxy fallback, rollout reward distribution +
reward_source, flag gating.
- **python** (2): stats contents + flag gating.

README updated.

## Checks

- `cargo test -p lance-context-core --lib` — 66 passed
- `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets
-- -D warnings` — clean
- `ruff format --check`, `ruff check`, `pyright` — clean
- `test_export_training.py` — 12 passed

## Acceptance criteria (#104)

- [x] Export optionally emits a stats JSON with the counts/distributions
above
- [x] Token stats use `state_metadata.tokens_used` when available, with
a documented fallback (whitespace word count)
- [x] Curation accounting reports excluded counts by reason
- [x] Stats computed in the export pass (no full re-materialization)
- [x] Tests verify counts/distributions against known fixtures

Closes #104
@dcfocus dcfocus deleted the feat/issue-96-curate-export branch June 27, 2026 19:04
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.

Curate lance-context records into trainable datasets (SFT/preference/rollout export)

1 participant