Problem
The encoder detectors run bidirectional token classification (ModernBERT / mmBERT / EuroBERT), which pays full-attention cost over the whole context. LFM2 is a hybrid architecture — mostly O(n) short-convolution layers with only a handful of full-attention layers — so a bidirectional LFM2 encoder should give long-context bidirectional encoding at a fraction of a standard encoder's cost, with a span-detection head on top.
Liquid AI has already proven the causal-to-bidirectional conversion: their LFM2.5 retrievers replace the causal attention mask with full bidirectional attention and make the short convolutions non-causal, releasing a 350M LFM2.5-Embedding (CLS pooling) and LFM2.5-ColBERT (per-token MaxSim) covering 11 languages. The per-token ColBERT variant shows the backbone already produces useful token-level representations. But the release targets short-context retrieval/embeddings — not long-context token classification. That gap is this issue.
Current behavior
Work has started on main; a contributor picks this up mid-flight rather than from zero:
scripts/train_lfm_span_detector.py exists and trains a token-level span tagger on the bidirectional backbone. LfmForSpanTagging (scripts/train_lfm_span_detector.py:39) wraps LiquidAI/LFM2.5-ColBERT-350M (loaded with trust_remote_code, exposing Lfm2BidirectionalModel) with LayerNorm + dropout + a linear head; the LayerNorm is required because the retriever's raw hidden states are not scaled for classification (large magnitudes saturate the logits — see the comment at scripts/train_lfm_span_detector.py:50-52). Row loading, label alignment, tokenization, and metrics are imported verbatim from scripts/train_span_detector.py (load_rows/tokenize_split/compute_metrics, lines 43/97/134 there), so it stays a thin backbone swap. Defaults: --model-name LiquidAI/LFM2.5-ColBERT-350M, --max-length 8192 (train_lfm_span_detector.py:88-90).
- A related decoder-side baseline is already operational: the generative LFM detector (LFM 8B SFT) is served with
scripts/experiments/serve_lfm.sh (vLLM, 32k context) and evaluated per-source / per-language with scripts/experiments/run_lfm_eval.sh via scripts/evaluate_generative_model.py. That is a separate track from the bidirectional encoder, but it shares the metric stack, so encoder results are directly comparable.
- The comparison target and shared scoring already exist:
jhu-clsp/mmBERT-base trained by scripts/train_span_detector.py on KRLabsOrg/lettucedetect-code-hallucination + KRLabsOrg/lettucedetect-prose-hallucination, scored with scripts/evaluate_span_model.py (char-span and example-level P/R/F1, --by dataset / --by language) on top of scripts/span_eval_metrics.py.
What to do
What remains, in order:
- Baseline the existing script: run
scripts/train_lfm_span_detector.py on the two published datasets and score with scripts/evaluate_span_model.py --split test — establish where the un-adapted backbone lands vs mmBERT-base before touching anything.
- Long-context adaptation: the backbone was trained for short retrieval inputs. Design a length curriculum (512 → 2k → 8k) on multilingual prose + code, and verify the non-causal convolution + positional handling actually behaves at 8k positions (the training script already accepts
--max-length 8192; the open question is representation quality there, not plumbing).
- Kill-gates, evaluated head-to-head against mmBERT-base on the same test split: (a) token-representation quality at 6-8k positions comparable to short-context level; (b) binary hallucination span char-F1 competitive with mmBERT-base. If either gate fails after honest effort, write the result up — a documented negative result closes this bet and is a valid contribution.
- Measure the actual motivation: throughput (samples/sec) and peak memory vs mmBERT-base at 4k and 8k input lengths, same hardware, same batch size.
- Optional: reproduce Liquid's bidirectional patches from a stock LFM2 checkpoint (bidirectional attention mask + non-causal symmetric convs) instead of starting from the ColBERT release, if the released backbone proves limiting.
Acceptance
This is a research issue: experiments, design notes, and negative results are all valid contributions. Concretely:
- results are produced by
scripts/evaluate_span_model.py on the public test split, so they are reproducible and comparable to the published encoder numbers;
- the report includes the throughput/memory comparison (item 4) — speed at long context is the point of the bet;
- training changes stay in
scripts/train_lfm_span_detector.py (or a sibling script with a descriptive name), runnable end-to-end with --limit for smoke tests;
- any code that moves into the
lettucedetect/ package gets unit tests under tests/ following the test_*_pytest.py pattern (tests/pytest.ini), run with python -m pytest tests/ -v; python tests/run_pytest.py stays green throughout.
Non-goals
- No production release commitment for the resulting model.
- No embedding / retrieval work — Liquid's release covers that; this is token classification only.
- No changes to the generative LFM SFT track (
scripts/experiments/serve_lfm.sh / run_lfm_eval.sh) — it is a comparison point, not the subject.
- No new metric code —
scripts/span_eval_metrics.py is the shared scorer.
Start here
git clone https://github.com/KRLabsOrg/LettuceDetect.git
cd LettuceDetect
pip install -e ".[dev]"
python tests/run_pytest.py # should be green before you change anything
# The code to read first:
sed -n '1,60p' scripts/train_lfm_span_detector.py # existing backbone wrapper + rationale
grep -n "def load_rows\|def tokenize_split\|def compute_metrics" scripts/train_span_detector.py
sed -n '1,14p' scripts/evaluate_span_model.py # how results get scored
Problem
The encoder detectors run bidirectional token classification (ModernBERT / mmBERT / EuroBERT), which pays full-attention cost over the whole context. LFM2 is a hybrid architecture — mostly O(n) short-convolution layers with only a handful of full-attention layers — so a bidirectional LFM2 encoder should give long-context bidirectional encoding at a fraction of a standard encoder's cost, with a span-detection head on top.
Liquid AI has already proven the causal-to-bidirectional conversion: their LFM2.5 retrievers replace the causal attention mask with full bidirectional attention and make the short convolutions non-causal, releasing a 350M
LFM2.5-Embedding(CLS pooling) andLFM2.5-ColBERT(per-token MaxSim) covering 11 languages. The per-token ColBERT variant shows the backbone already produces useful token-level representations. But the release targets short-context retrieval/embeddings — not long-context token classification. That gap is this issue.Current behavior
Work has started on main; a contributor picks this up mid-flight rather than from zero:
scripts/train_lfm_span_detector.pyexists and trains a token-level span tagger on the bidirectional backbone.LfmForSpanTagging(scripts/train_lfm_span_detector.py:39) wrapsLiquidAI/LFM2.5-ColBERT-350M(loaded withtrust_remote_code, exposingLfm2BidirectionalModel) with LayerNorm + dropout + a linear head; the LayerNorm is required because the retriever's raw hidden states are not scaled for classification (large magnitudes saturate the logits — see the comment atscripts/train_lfm_span_detector.py:50-52). Row loading, label alignment, tokenization, and metrics are imported verbatim fromscripts/train_span_detector.py(load_rows/tokenize_split/compute_metrics, lines 43/97/134 there), so it stays a thin backbone swap. Defaults:--model-name LiquidAI/LFM2.5-ColBERT-350M,--max-length 8192(train_lfm_span_detector.py:88-90).scripts/experiments/serve_lfm.sh(vLLM, 32k context) and evaluated per-source / per-language withscripts/experiments/run_lfm_eval.shviascripts/evaluate_generative_model.py. That is a separate track from the bidirectional encoder, but it shares the metric stack, so encoder results are directly comparable.jhu-clsp/mmBERT-basetrained byscripts/train_span_detector.pyonKRLabsOrg/lettucedetect-code-hallucination+KRLabsOrg/lettucedetect-prose-hallucination, scored withscripts/evaluate_span_model.py(char-span and example-level P/R/F1,--by dataset/--by language) on top ofscripts/span_eval_metrics.py.What to do
What remains, in order:
scripts/train_lfm_span_detector.pyon the two published datasets and score withscripts/evaluate_span_model.py --split test— establish where the un-adapted backbone lands vs mmBERT-base before touching anything.--max-length 8192; the open question is representation quality there, not plumbing).Acceptance
This is a research issue: experiments, design notes, and negative results are all valid contributions. Concretely:
scripts/evaluate_span_model.pyon the public test split, so they are reproducible and comparable to the published encoder numbers;scripts/train_lfm_span_detector.py(or a sibling script with a descriptive name), runnable end-to-end with--limitfor smoke tests;lettucedetect/package gets unit tests undertests/following thetest_*_pytest.pypattern (tests/pytest.ini), run withpython -m pytest tests/ -v;python tests/run_pytest.pystays green throughout.Non-goals
scripts/experiments/serve_lfm.sh/run_lfm_eval.sh) — it is a comparison point, not the subject.scripts/span_eval_metrics.pyis the shared scorer.Start here