Skip to content

Research methods

Everything a methods section (or an IRB protocol) asks of an LLM pipeline, with the receipts to back it: a reproducible sample, a measured agreement number, a citable record of every run, and a hard guarantee that sensitive inputs were processed locally rather than uploaded.

The free examples below are real runs; the corpus is 1,000 labeled tickets ({"ticket": "T-0001", "label": "bug"}, 70% bug / 20% feature / 10% question).

1. Draw the annotation sample - stratified, seeded, citable

A plain random subset can under-hit rare classes. sample --by keeps the class balance (proportional allocation, largest-remainder rounding, total exactly N), and the default seed makes the subset citable - "seed 0" in the paper reselects the same rows from the same file:

smartpipe sample 10 --by label < labeled.jsonl > subset.jsonl
# stderr → note: sample: 10 of 1,000 (seed 0, 3 strata by 'label')

smartpipe summarize 'count() by label' < subset.jsonl
{"label":"bug","count":7}
{"label":"feature","count":2}
{"label":"question","count":1}

The 70/20/10 corpus produced a 7/2/1 sample - by construction, not by luck. Rows missing the field sample as their own null stratum (disclosed on stderr), so unlabeled data can't silently vanish.

2. Measure inter-rater agreement - free, zero config

Two coders double-coded 40 tickets (or: your model labeled them, and you have gold). agree aligns rows by a shared key and reports the numbers reviewers expect:

smartpipe agree rater1.jsonl rater2.jsonl --on id
{"n":40,"observed_agreement":0.925,"cohen_kappa":0.8819,"krippendorff_alpha":0.8828,"label_a":null,"label_b":null,"count":null}
{"n":null,"observed_agreement":null,"cohen_kappa":null,"krippendorff_alpha":null,"label_a":"feature","label_b":"feature","count":17}
{"n":null,"observed_agreement":null,"cohen_kappa":null,"krippendorff_alpha":null,"label_a":"bug","label_b":"bug","count":12}
{"n":null,"observed_agreement":null,"cohen_kappa":null,"krippendorff_alpha":null,"label_a":"question","label_b":"question","count":8}
{"n":null,"observed_agreement":null,"cohen_kappa":null,"krippendorff_alpha":null,"label_a":"bug","label_b":"feature","count":3}

Kappa and alpha are hand-verified against the published worked examples, and the degenerate single-class case honestly reports null (undefined), never a flattering 1.0. Model-vs-gold works the same way:

smartpipe extend "Classify: {label enum(bug, feature, question)}" --output json \
  < subset.jsonl > model.jsonl
smartpipe agree model.jsonl gold.jsonl --on ticket

3. Record the run - the manifest IS the methods paragraph

Every model verb takes --manifest PATH. At run end (partial and belted runs included) smartpipe writes one JSON file: version, verb and raw argv, the resolved model for every role, the prompt text and its sha256, the compiled schema, the pinned temperature (0.0 - runs are reproducible by default), item counts, the token/conversion receipt, UTC start/end stamps, and the exit status.

smartpipe extend "Classify: {label enum(bug, feature, question)}" \
  --manifest run-manifest.json < subset.jsonl > model.jsonl

The file answers "which model, which prompt, over how many items, at what cost, ending how" - copy the numbers straight into the paper, or archive the file next to the outputs. It is written atomically and records THIS run; a rerun overwrites it. A typo'd --manifest directory faults before any spend.

{
  "manifest_version": 1,
  "smartpipe_version": "1.5.1",
  "verb": "extend",
  "argv": ["extend", "Classify: {label enum(bug, feature, question)}",
           "--manifest", "run-manifest.json"],
  "models": {"chat": "ollama/qwen3:8b"},
  "prompt": {"text": "Classify: {label enum(bug, feature, question)}",
             "sha256": "ee8dff5180d5e7f054a0ba144a64f2ca212629bc5c091ed4d549bff0d3db4c40"},
  "schema": {"type": "object", "...": "..."},
  "determinism": {"temperature": 0.0},
  "items": {"in": 10, "succeeded": 10, "skipped": 0, "failed": 0},
  "receipt": {"tokens_in": 1968, "tokens_out": 142, "paid_conversions": 0},
  "run": {"started_at": "2026-07-10T17:02:11Z", "finished_at": "2026-07-10T17:02:19Z",
          "exit_code": 0, "exit_status": "ok"}
}

4. Keep the data on the machine - the IRB checkbox

smartpipe --local-only map "Redact any patient name: {text}" < notes.jsonl

With --local-only (or SMARTPIPE_LOCAL_ONLY=1), model execution is local and input is not uploaded - and the protocol can say so. The fence is enforced where models are built, before spend: a cloud wire refuses with exit 2, naming the offender and the local alternative:

$ smartpipe --local-only map "..." --model gpt-4o-mini < notes.jsonl
error: --local-only forbids the cloud chat wire 'openai/gpt-4o-mini'
  With --local-only, input stays on this machine - openai is a cloud endpoint.
  Local chat runs on ollama: smartpipe use ollama   (install: https://ollama.com)

The fence covers every role (chat, embeddings, media embeddings, OCR, transcription). It is not an air-gap switch: a first local run may download model artifacts, and other supporting requests are allowed when they carry no user payload. The daily update ping and model catalogs happen to remain suppressed in fenced runs. It is also honest about indirection: a remote OLLAMA_HOST is refused, because sending items to another box IS data leaving. What still works, fully local: ollama on localhost, the on-device embedder, local whisper transcription, the local document-extraction ladder, and graph --fast's on-device NER.

The whole study, end to end

export SMARTPIPE_LOCAL_ONLY=1                                # inputs stay local
smartpipe sample 200 --by label < corpus.jsonl > subset.jsonl # citable subset
smartpipe extend "Classify: {label enum(bug, feature, question)}" \
  --manifest run-manifest.json < subset.jsonl > model.jsonl   # recorded run
smartpipe agree model.jsonl gold.jsonl --on ticket            # the headline number

Four lines, and the methods section writes itself: the seed, the strata, the manifest, the kappa - and the sentence "inputs were not uploaded."